├── .gitignore ├── README.md ├── __init__.py ├── advisor.py ├── brickmover.conf.example ├── config.py ├── exchanges ├── __init__.py ├── btcchina.py ├── btce.py ├── mtgox2.py └── okcoin.py ├── info.py ├── main.py ├── models.py ├── pygsm ├── LICENSE ├── README ├── __init__.py ├── devicewrapper.py ├── errors.py ├── gsmcodecs │ ├── __init__.py │ ├── gsm0338.py │ └── mappings │ │ └── GSM0338.TXT ├── gsmmodem.py ├── gsmpdu.py ├── message │ ├── __init__.py │ ├── incoming.py │ └── outgoing.py ├── pdusmshandler.py ├── smshandler.py └── textsmshandler.py ├── pygsm_demo ├── sms.py └── wallet.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | bin/ 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Installer logs 24 | pip-log.txt 25 | pip-delete-this-directory.txt 26 | 27 | # Unit test / coverage reports 28 | .tox/ 29 | .coverage 30 | .cache 31 | nosetests.xml 32 | coverage.xml 33 | 34 | # Translations 35 | *.mo 36 | 37 | # Mr Developer 38 | .mr.developer.cfg 39 | .project 40 | .pydevproject 41 | 42 | # Rope 43 | .ropeproject 44 | 45 | # Django stuff: 46 | *.log 47 | *.pot 48 | 49 | # Sphinx documentation 50 | docs/_build/ 51 | 52 | brickmover.conf 53 | brickmover.db 54 | 55 | *.swp 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 一个简单的比特币交易所价差套利程序。 3 | 4 | 5 | 版权所有 (C) 2013-2015 李维 6 | 7 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | #encoding: utf-8 2 | 3 | import config 4 | import info 5 | -------------------------------------------------------------------------------- /advisor.py: -------------------------------------------------------------------------------- 1 | #!/bin/python 2 | #encoding: utf-8 3 | 4 | import time 5 | import datetime 6 | import multiprocessing as mp 7 | import logging 8 | 9 | from info import * 10 | import config 11 | import models 12 | from wallet import Wallet 13 | import exchanges 14 | 15 | 16 | def _polled_request_account_info(k): 17 | try: 18 | _logger = logging.getLogger(__name__) 19 | _logger.debug('Requesting exchange info...' + k) 20 | ai = Advisor._exchanges[k].request_info() 21 | _logger.debug(k + ' finished') 22 | return ai 23 | except IOError as e: 24 | _logger.error(e) 25 | return None 26 | 27 | class Advisor: 28 | _exchanges = exchanges.actived_exchanges 29 | 30 | def __init__(self): 31 | self._logger = logging.getLogger(__name__) 32 | self.wallet = Wallet() 33 | self._pool = mp.Pool(2) 34 | self.qty_per_order = config.configuration['qty_per_order'] 35 | 36 | def close(self): 37 | self._pool.close() 38 | self._pool.join() 39 | self._pool = None 40 | 41 | def request_accounts_info(self): 42 | self._logger.info('Requesting all accounts info...') 43 | accounts = self._pool.map(_polled_request_account_info, Advisor._exchanges) 44 | #accounts = map(_polled_request_account_info, Advisor._exchanges) 45 | for a in accounts: 46 | if a == None: 47 | self._logger.info('ERROR: Failed to request account info') 48 | raise RuntimeError('Failed to request account info') 49 | #accounts = map(_polled_request_account_info, Advisor._exchanges) 50 | self._logger.info('All Accounts info accquired') 51 | self._logger.debug('ACCOUNT INFO:') 52 | for a in accounts: 53 | self._logger.debug('\texchange={0}\tBTC_balance={1}\tmoney_balance={2}\tbuy={3}'.format( 54 | a.name, round(a.stock_balance, 4), round(a.money_balance, 2), round(a.ticker.buy_price, 2))) 55 | return accounts 56 | 57 | def _record_trade_lead(self, buy_exchange, buy_price, sell_exchange, sell_price): 58 | session = models.Session() 59 | try: 60 | tl = models.TradeLead(buy_exchange, buy_price, sell_exchange, sell_price) 61 | session.add(tl) 62 | session.commit() 63 | except Exception as e: 64 | self._logger.error(e) 65 | session.rollback() 66 | 67 | def evaluate(self, accounts): 68 | try: 69 | return self._do_evaluate(accounts) 70 | except IOError as e: 71 | self._logger.error('Network Error') 72 | self._logger.error(e) 73 | return None 74 | except Exception as e: 75 | self._logger.error(e) 76 | return None 77 | 78 | def _do_evaluate(self, accounts): 79 | #计算价格 80 | accounts = sorted(accounts, key=lambda e:e.ticker.buy_price) 81 | buy_account = accounts[0] 82 | sell_account = accounts[len(accounts) - 1] 83 | buy_price_rate = 1.001 84 | sell_price_rate = 0.999 85 | buy_price = round(buy_account.ticker.buy_price * buy_price_rate, 2) 86 | sell_price = round(sell_account.ticker.buy_price * sell_price_rate, 2) 87 | buy_amount = buy_price * self.qty_per_order 88 | sell_amount = round(sell_price * self.qty_per_order, 2) 89 | wallet_transfer_fee_amount = round(buy_price * self.wallet.transfer_fee + buy_price * 0.0001, 2) 90 | trade_fee_amount = round(buy_amount * buy_account.trade_fee + sell_amount * sell_account.trade_fee, 2) 91 | gross_profit = round(sell_amount - buy_amount, 2) 92 | net_profit = round(sell_amount - buy_amount - wallet_transfer_fee_amount - trade_fee_amount, 2) 93 | profit_rate = net_profit / buy_amount 94 | threshold = config.configuration['profit_rate_threshold'] 95 | self._logger.debug('threshold_rate={0}%\tgross_profit={1}\t\tnet_profit={2}\tprofit_rate={3}%'.format( 96 | round(threshold * 100, 4), gross_profit, net_profit, round(profit_rate * 100, 4))) 97 | self._logger.debug('\tbuy_price={0}\tbuy_amount={1}\tsell_price={2}\tsell_amount={3}'.format(buy_price, buy_amount, sell_price, sell_amount)) 98 | self._logger.debug('\twallet_fee={0}\ttrade_fee={1}'.format(wallet_transfer_fee_amount, trade_fee_amount)) 99 | is_balance_ok = buy_account.money_balance > buy_amount and sell_account.stock_balance > sef.qty_per_order 100 | if not is_balance_ok: 101 | self._logger.warn(u'帐号余额不足') 102 | can_go = is_balance_ok and profit_rate > threshold and net_profit > 0.01 #and net_profit < 0.05 103 | if can_go: 104 | self._record_trade_lead( 105 | buy_account.name, buy_account.ticker.buy_price, 106 | sell_account.name, sell_account.ticker.sell_price) 107 | 108 | s = Suggestion(can_go, buy_account, sell_account, buy_price, sell_price, self.qty_per_order) 109 | if s.can_go: 110 | self._logger.info('I FOUND A CHANCE AND I WILL TRADE IT NOW!!!') 111 | return s 112 | -------------------------------------------------------------------------------- /brickmover.conf.example: -------------------------------------------------------------------------------- 1 | { 2 | "data_path": "/home/oldrev/tmp/brickmover", 3 | "is_simulation": true, 4 | "exchanges": { 5 | "btcchina": { 6 | "access_key": "", 7 | "secret_key": "" , 8 | "user_name": "", 9 | "password": "", 10 | "trade_password": "", 11 | "trade_fee": 0.0 12 | }, 13 | 14 | "okcoin": { 15 | "access_key": "", 16 | "secret_key": "", 17 | "user_name": "", 18 | "password": "", 19 | "trade_password": "", 20 | "trade_fee": 0.0 21 | } 22 | }, 23 | 24 | "wallet": { 25 | "uerid": "", 26 | "password": "" 27 | }, 28 | 29 | "qty_per_order": 0.01, 30 | "profit_rate_threshold": 0.01, 31 | "trade_interval": 45 32 | } 33 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | #encoding: utf-8 2 | 3 | import logging 4 | import logging.config 5 | import json 6 | import os.path 7 | 8 | logging.basicConfig() 9 | 10 | 11 | def load_config(cfg_path): 12 | with open(cfg_path, 'r') as f: 13 | return json.loads(f.read()) 14 | 15 | 16 | def config_logger(): 17 | # set up logging to file - see previous section for more details 18 | log_path = os.path.join(configuration['data_path'],'log') 19 | logging.config.dictConfig({ 20 | 'version': 1, 21 | 'disable_existing_loggers': False, # this fixes the problem 22 | 'formatters': { 23 | 'standard': { 24 | 'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s' 25 | }, 26 | 'console': { 27 | 'format': '[%(levelname)s] %(message)s' 28 | } 29 | }, 30 | 'handlers': { 31 | 'default': { 32 | 'level':'DEBUG', 33 | 'class':'logging.StreamHandler', 34 | "formatter": "console", 35 | }, 36 | "info_file_handler": { 37 | "class": "logging.handlers.RotatingFileHandler", 38 | "level": "INFO", 39 | "formatter": "standard", 40 | "filename": os.path.join(log_path, 'brickmover.info.log'), 41 | "maxBytes": "10485760", 42 | "backupCount": "20", 43 | "encoding": "utf8" 44 | }, 45 | "error_file_handler": { 46 | "class": "logging.handlers.RotatingFileHandler", 47 | "level": "ERROR", 48 | "formatter": "standard", 49 | "filename": os.path.join(log_path, 'brickmover.error.log'), 50 | "maxBytes": "10485760", 51 | "backupCount": "20", 52 | "encoding": "utf8" 53 | }, 54 | }, 55 | 'loggers': { 56 | '': { 57 | 'handlers': ['default', 'info_file_handler', 'error_file_handler'], 58 | 'level': 'DEBUG', 59 | 'propagate': True 60 | } 61 | } 62 | }) 63 | 64 | 65 | cfg_path = os.path.join(os.getenv('HOME'), 'etc/brickmover.conf') 66 | configuration = load_config(cfg_path) 67 | config_logger() 68 | -------------------------------------------------------------------------------- /exchanges/__init__.py: -------------------------------------------------------------------------------- 1 | #encoding: utf-8 2 | 3 | import json 4 | import httplib 5 | import datetime 6 | import cookielib 7 | import urllib, urllib2 8 | import random 9 | import logging 10 | import os 11 | import sys 12 | import lxml.html 13 | import lxml.etree 14 | 15 | import btcchina 16 | import okcoin 17 | 18 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 19 | 20 | from brickmover import config 21 | 22 | cfg = config.configuration['exchanges'] 23 | actived_exchanges = {'btcchina': btcchina.BtcChinaExchange(cfg['btcchina']), 'okcoin': okcoin.OKCoinExchange(cfg['okcoin'])} 24 | 25 | _logger = logging.getLogger('exchanges') 26 | 27 | 28 | -------------------------------------------------------------------------------- /exchanges/btcchina.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import time 5 | import re 6 | import hmac 7 | import hashlib 8 | import base64 9 | import httplib 10 | import json 11 | import os, os.path 12 | import urllib, urllib2 13 | import cookielib 14 | import logging 15 | 16 | from brickmover import config 17 | from brickmover.info import * 18 | 19 | class BtcChinaInterface(): 20 | def __init__(self,access=None,secret=None): 21 | self.access_key=access 22 | self.secret_key=secret 23 | self.conn=httplib.HTTPSConnection("api.btcchina.com") 24 | 25 | def _get_tonce(self): 26 | return int(time.time()*1000000) 27 | 28 | def _get_params_hash(self,pdict): 29 | pstring="" 30 | # The order of params is critical for calculating a correct hash 31 | fields=['tonce','accesskey','requestmethod','id','method','params'] 32 | for f in fields: 33 | if pdict[f]: 34 | if f == 'params': 35 | # Convert list to string, then strip brackets and spaces 36 | # probably a cleaner way to do this 37 | param_string=re.sub("[\[\] ]","",str(pdict[f])) 38 | param_string=re.sub("'",'',param_string) 39 | pstring+=f+'='+param_string+'&' 40 | else: 41 | pstring+=f+'='+str(pdict[f])+'&' 42 | else: 43 | pstring+=f+'=&' 44 | pstring=pstring.strip('&') 45 | 46 | # now with correctly ordered param string, calculate hash 47 | phash = hmac.new(self.secret_key, pstring, hashlib.sha1).hexdigest() 48 | return phash 49 | 50 | def _private_request(self,post_data): 51 | #fill in common post_data parameters 52 | tonce=self._get_tonce() 53 | post_data['tonce']=tonce 54 | post_data['accesskey']=self.access_key 55 | post_data['requestmethod']='post' 56 | 57 | # If ID is not passed as a key of post_data, just use tonce 58 | if not 'id' in post_data: 59 | post_data['id']=tonce 60 | 61 | pd_hash=self._get_params_hash(post_data) 62 | 63 | # must use b64 encode 64 | auth_string='Basic '+base64.b64encode(self.access_key+':'+pd_hash) 65 | headers={'Authorization':auth_string,'Json-Rpc-Tonce':tonce} 66 | 67 | #post_data dictionary passed as JSON 68 | self.conn.request("POST",'/api_trade_v1.php',json.dumps(post_data),headers) 69 | response = self.conn.getresponse() 70 | 71 | # check response code, ID, and existence of 'result' or 'error' 72 | # before passing a dict of results 73 | if response.status == 200: 74 | # this might fail if non-json data is returned 75 | resp_dict = json.loads(response.read()) 76 | 77 | # The id's may need to be used by the calling application, 78 | # but for now, check and discard from the return dict 79 | if str(resp_dict['id']) == str(post_data['id']): 80 | if 'result' in resp_dict: 81 | return resp_dict['result'] 82 | elif 'error' in resp_dict: 83 | return resp_dict['error'] 84 | else: 85 | # not great error handling.... 86 | raise IOError('Request error') 87 | 88 | return None 89 | 90 | def get_account_info(self,post_data={}): 91 | post_data['method']='getAccountInfo' 92 | post_data['params']=[] 93 | return self._private_request(post_data) 94 | 95 | def get_market_depth(self,post_data={}): 96 | post_data['method']='getMarketDepth' 97 | post_data['params']=[] 98 | return self._private_request(post_data) 99 | 100 | def buy(self,price,amount,post_data={}): 101 | post_data['method']='buyOrder' 102 | post_data['params']=[price,amount] 103 | return self._private_request(post_data) 104 | 105 | def sell(self,price,amount,post_data={}): 106 | post_data['method']='sellOrder' 107 | post_data['params']=[price,amount] 108 | return self._private_request(post_data) 109 | 110 | def cancel(self,order_id,post_data={}): 111 | post_data['method']='cancelOrder' 112 | post_data['params']=[order_id] 113 | return self._private_request(post_data) 114 | 115 | def request_withdrawal(self,currency,amount,post_data={}): 116 | post_data['method']='requestWithdrawal' 117 | post_data['params']=[currency,amount] 118 | return self._private_request(post_data) 119 | 120 | def get_deposits(self,currency='BTC',pending=True,post_data={}): 121 | post_data['method']='getDeposits' 122 | if pending: 123 | post_data['params']=[currency] 124 | else: 125 | post_data['params']=[currency,'false'] 126 | return self._private_request(post_data) 127 | 128 | def get_orders(self,id=None,open_only=True,post_data={}): 129 | # this combines getOrder and getOrders 130 | if id is None: 131 | post_data['method']='getOrders' 132 | if open_only: 133 | post_data['params']=[] 134 | else: 135 | post_data['params']=['false'] 136 | else: 137 | post_data['method']='getOrder' 138 | post_data['params']=[id] 139 | return self._private_request(post_data) 140 | 141 | def get_withdrawals(self,id='BTC',pending=True,post_data={}): 142 | # this combines getWithdrawal and getWithdrawls 143 | try: 144 | id = int(id) 145 | post_data['method']='getWithdrawal' 146 | post_data['params']=[id] 147 | except: 148 | post_data['method']='getWithdrawals' 149 | if pending: 150 | post_data['params']=[id] 151 | else: 152 | post_data['params']=[id,'false'] 153 | return self._private_request(post_data) 154 | 155 | 156 | class BtcChinaExchange: 157 | Name = 'btcchina' 158 | session_period = 30 159 | HOST = 'api.btcchina.com' 160 | 161 | def __init__(self, cfg): 162 | self._logger = logging.getLogger('BtcChinaExchange') 163 | self._config = cfg 164 | self.can_withdraw_stock_to_address = False 165 | self._last_logged_time = None 166 | self.stock_withdraw_fee = 0.0001 167 | self.username = self._config['user_name'] 168 | self.password = self._config['password'] 169 | self.cookie_file = os.path.join(config.configuration['data_path'], 'btcchina.cookies') 170 | self.cookieJar = cookielib.MozillaCookieJar(self.cookie_file) 171 | self.trade_fee = self._config['trade_fee'] 172 | access = self._config['access_key'].encode('utf-8') 173 | secret = self._config['secret_key'].encode('utf-8') 174 | self._btcchina = BtcChinaInterface(access, secret) 175 | # set up opener to handle cookies, redirects etc 176 | self.opener = urllib2.build_opener( 177 | urllib2.HTTPRedirectHandler(), 178 | urllib2.HTTPHandler(debuglevel=0), 179 | urllib2.HTTPSHandler(debuglevel=0), 180 | urllib2.HTTPCookieProcessor(self.cookieJar) 181 | ) 182 | # pretend we're a web browser and not a python script 183 | self.opener.addheaders = [('User-agent', 184 | ('Mozilla/4.0 (compatible; MSIE 10.0; ' 185 | 'Windows NT 5.2; .NET CLR 1.1.4322)')) 186 | ] 187 | 188 | def _send_vcode(self): 189 | self.login() 190 | response = self.opener.open('https://vip.btcchina.com/account/withdraw.btc') 191 | html = response.read() 192 | tree = lxml.html.document_fromstring(html) 193 | #vcode_hidden = tree.xpath('//input[@id="vcode_id"]') 194 | vcode_hidden = tree.xpath('//input') 195 | print "######" 196 | print vcode_hidden 197 | response.close() 198 | pass 199 | 200 | def login(self): 201 | ''' 202 | 203 | if self._last_logged_time and ((datetime.datetime.now() - self._last_logged_time).total_seconds() < BtcChinaExchange.session_period * 60): 204 | return 205 | base_url = 'https://vip.btcchina.com' 206 | self._last_logged_time = datetime.datetime.now() 207 | #open the front page of the website to set and save initial cookies 208 | login_url = base_url + '/bbs/ucp.php?mode=login' 209 | response = self.opener.open(login_url) 210 | self.cookieJar.save() 211 | response.close() 212 | login_data = urllib.urlencode({ 213 | 'username' : self.username, 214 | 'password' : self.password, 215 | 'redirect' : '/trade', 216 | }) 217 | response = self.opener.open(login_url, login_data) 218 | self.cookieJar.save() 219 | response.close() 220 | ''' 221 | pass 222 | 223 | def request_ticker(self): 224 | url = 'https://data.btcchina.com/data/ticker' 225 | response = urllib2.urlopen(url, timeout=10) 226 | ticker_data = json.loads(response.read())['ticker'] 227 | ticker = Ticker(float(ticker_data['buy']), float(ticker_data['sell']), float(ticker_data['last'])) 228 | return ticker 229 | 230 | def request_info(self): 231 | self._logger.info(u'准备开始请求 btcchina.com 帐号信息') 232 | self.login() 233 | ticker = self.request_ticker() 234 | ai = self._btcchina.get_account_info() 235 | account_info = AccountInfo(BtcChinaExchange.Name, ticker, 236 | self.trade_fee, 237 | float(ai['balance']['cny']['amount']), 238 | float(ai['balance']['btc']['amount']), 239 | ai['profile']['btc_deposit_address']) 240 | return account_info 241 | 242 | def withdraw_stock(self, address, amount): 243 | self._btcchina.request_withdrawal('btc', amount) 244 | 245 | def buy(self, stock_qty, price): 246 | self._btcchina.buy(price, stock_qty) 247 | 248 | def sell(self, stock_qty, price): 249 | self._btcchina.sell(price, stock_qty) 250 | 251 | -------------------------------------------------------------------------------- /exchanges/btce.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | ## Author: t0pep0 3 | ## e-mail: t0pep0.gentoo@gmail.com 4 | ## Jabber: t0pep0@jabber.ru 5 | ## BTC : 1ipEA2fcVyjiUnBqUx7PVy5efktz2hucb 6 | ## donate free =) 7 | import httplib 8 | import urllib, urllib2 9 | import json 10 | import hashlib 11 | import hmac 12 | import time, datetime 13 | import os 14 | import cookielib 15 | 16 | from brickmover import config 17 | from brickmover.info import * 18 | 19 | 20 | class BTCEInterface: 21 | __api_key = ''; 22 | __api_secret = '' 23 | __nonce_v = 1; 24 | __wait_for_nonce = False 25 | 26 | def __init__(self,api_key,api_secret,wait_for_nonce=False): 27 | self.__api_key = api_key 28 | self.__api_secret = api_secret 29 | self.__wait_for_nonce = wait_for_nonce 30 | 31 | def __nonce(self): 32 | if self.__wait_for_nonce: 33 | time.sleep(1) 34 | self.__nonce_v = str(time.time()).split('.')[0] 35 | 36 | def __signature(self, params): 37 | return hmac.new(self.__api_secret, params, digestmod=hashlib.sha512).hexdigest() 38 | 39 | def __api_call(self,method,params): 40 | self.__nonce() 41 | params['method'] = method 42 | params['nonce'] = str(self.__nonce_v) 43 | params = urllib.urlencode(params) 44 | headers = {"Content-type" : "application/x-www-form-urlencoded", 45 | "Key" : self.__api_key, 46 | "Sign" : self.__signature(params)} 47 | conn = httplib.HTTPSConnection("btc-e.com") 48 | conn.request("POST", "/tapi", params, headers) 49 | response = conn.getresponse() 50 | data = json.load(response) 51 | conn.close() 52 | return data 53 | 54 | def get_param(self, couple, param): 55 | conn = httplib.HTTPSConnection("btc-e.com") 56 | conn.request("GET", "/api/2/"+couple+"/"+param) 57 | response = conn.getresponse() 58 | data = json.load(response) 59 | conn.close() 60 | return data 61 | 62 | def get_info(self): 63 | r = self.__api_call('getInfo', {}) 64 | return r['return'] 65 | 66 | def get_trans_history(self, tfrom, tcount, tfrom_id, tend_id, torder, tsince, tend): 67 | params = { 68 | "from" : tfrom, 69 | "count" : tcount, 70 | "from_id" : tfrom_id, 71 | "end_id" : tend_id, 72 | "order" : torder, 73 | "since" : tsince, 74 | "end" : tend} 75 | return self.__api__call('TransHistory', params) 76 | 77 | def get_trade_history(self, tfrom, tcount, tfrom_id, tend_id, torder, tsince, tend, tpair): 78 | params = { 79 | "from" : tfrom, 80 | "count" : tcount, 81 | "from_id" : tfrom_id, 82 | "end_id" : tend_id, 83 | "order" : torder, 84 | "since" : tsince, 85 | "end" : tend, 86 | "pair" : tpair} 87 | return self.__api_call('TradeHistory', params) 88 | 89 | def get_active_orders(self, tpair): 90 | params = { "pair" : tpair } 91 | return self.__api_call('ActiveOrders', params) 92 | 93 | def trade(self, tpair, ttype, trate, tamount): 94 | params = { 95 | "pair" : tpair, 96 | "type" : ttype, 97 | "rate" : trate, 98 | "amount" : tamount} 99 | r = self.__api_call('Trade', params) 100 | if r['success'] != 1: 101 | raise Exception('BTCEInterface: Failed to trade') 102 | 103 | def cancel_order(self, torder_id): 104 | params = { "order_id" : torder_id } 105 | return self.__api_call('CancelOrder', params) 106 | 107 | 108 | 109 | class BTCEExchange: 110 | session_period = 15 111 | 112 | def __init__(self, cfg): 113 | self._config = cfg 114 | self.can_withdraw_stock_to_address = True 115 | self.stock_withdraw_fee = 0.0005 116 | self.trade_fee = 0.005 117 | access = self._config['access_key'].encode('utf-8') 118 | secret = self._config['secret_key'].encode('utf-8') 119 | self.username = self._config['user_name'] 120 | self.password = self._config['password'] 121 | self.stock_deposit_address = self._config['stock_deposit_address'] 122 | self._last_logged_time = None 123 | self._btce = BTCEInterface(access, secret) 124 | self.cookie_file = os.path.join(config.configuration['data_path'], 'btce.cookies') 125 | self.cookieJar = cookielib.MozillaCookieJar(self.cookie_file) 126 | self.opener = urllib2.build_opener( 127 | urllib2.HTTPRedirectHandler(), 128 | urllib2.HTTPHandler(debuglevel=0), 129 | urllib2.HTTPSHandler(debuglevel=0), 130 | urllib2.HTTPCookieProcessor(self.cookieJar) 131 | ) 132 | # pretend we're a web browser and not a python script 133 | self.opener.addheaders = [('User-agent', 134 | ('Mozilla/4.0 (compatible; MSIE 10.0; ' 135 | 'Windows NT 5.2; .NET CLR 1.1.4322)')) 136 | ] 137 | 138 | 139 | def login(self): 140 | if self._last_logged_time and ((datetime.datetime.now() - self._last_logged_time).total_seconds() < BTCEExchange.session_period * 60): 141 | return 142 | base_url = 'https://btc-e.com' 143 | self._last_logged_time = datetime.datetime.now() 144 | 145 | # open the front page of the website to set and save initial cookies 146 | response = self.opener.open(base_url) 147 | self.cookieJar.save() 148 | response.close() 149 | 150 | login_data = urllib.urlencode({ 151 | 'email' : self.username, 152 | 'password' : self.password 153 | }) 154 | login_action = '/login' 155 | login_url = base_url + login_action 156 | response = self.opener.open(login_url, login_data) 157 | self.cookieJar.save() 158 | response.close() 159 | 160 | 161 | def request_ticker(self): 162 | url = 'https://btc-e.com/api/2/btc_usd/ticker' 163 | response = urllib2.urlopen(url, timeout=10) 164 | ticker_data = json.loads(response.read())['ticker'] 165 | ticker = Ticker(float(ticker_data['buy']), float(ticker_data['sell']), float(ticker_data['last'])) 166 | return ticker 167 | 168 | 169 | def request_info(self): 170 | self.login() 171 | ticker = self.request_ticker() 172 | ai = self._btce.get_info() 173 | account_info = AccountInfo('btce', ticker, 174 | self.trade_fee, 175 | float(ai['funds']['usd']), 176 | float(ai['funds']['btc']), 177 | self.stock_deposit_address) 178 | return account_info 179 | 180 | def buy(self, stock_qty, price): 181 | self.login() 182 | self._btce.trade('btc_usd', 'buy', price, stock_qty) 183 | 184 | def sell(self, stock_qty, price): 185 | self._btce.trade('btc_usd', 'sell', price, stock_qty) 186 | 187 | def withdraw_stock(self, amount): 188 | params = {'address': None, 'amount_int': int(amount * 1e8), 'fee_int': 0} 189 | r = self.request('money/bitcoin/send_simple', ) 190 | return r 191 | -------------------------------------------------------------------------------- /exchanges/mtgox2.py: -------------------------------------------------------------------------------- 1 | #encoding: utf-8 2 | 3 | import base64, hashlib, hmac, urllib2, time, urllib, json 4 | import logging 5 | 6 | from brickmover import config 7 | from brickmover.info import * 8 | 9 | base = 'https://data.mtgox.com/api/2/' 10 | 11 | _logger = logging.getLogger('exchanges') 12 | 13 | def post_request(key, secret, path, data): 14 | hmac_obj = hmac.new(secret, path + chr(0) + data, hashlib.sha512) 15 | hmac_sign = base64.b64encode(hmac_obj.digest()) 16 | 17 | header = { 18 | 'Content-Type': 'application/x-www-form-urlencoded', 19 | 'User-Agent': 'gox2 based client', 20 | 'Rest-Key': key, 21 | 'Rest-Sign': hmac_sign, 22 | } 23 | 24 | request = urllib2.Request(base + path, data, header) 25 | response = urllib2.urlopen(request, data) 26 | return json.load(response) 27 | 28 | 29 | def gen_tonce(): 30 | return str(int(time.time() * 1e6)) 31 | 32 | 33 | class MtGoxInterface: 34 | 35 | def __init__(self, key, secret): 36 | self.key = key 37 | self.secret = base64.b64decode(secret) 38 | 39 | def request(self, path, params={}): 40 | params = dict(params) 41 | params['tonce'] = gen_tonce() 42 | data = urllib.urlencode(params) 43 | 44 | result = post_request(self.key, self.secret, path, data) 45 | if result['result'] == 'success': 46 | return result['data'] 47 | else: 48 | raise Exception(result['result']) 49 | 50 | def get_account_info(self): 51 | r = self.request('BTCUSD/money/info') 52 | return r 53 | 54 | 55 | def get_stock_deposit_address(self): 56 | return self.request('money/bitcoin/address')['addr'] 57 | 58 | 59 | class MtGoxExchange: 60 | 61 | def __init__(self, cfg): 62 | self._config = cfg 63 | self.can_withdraw_stock_to_address = True 64 | self.stock_withdraw_fee = 0.0005 65 | self.trade_fee = 0.006 66 | access = self._config['access_key'].encode('utf-8') 67 | secret = self._config['secret_key'].encode('utf-8') 68 | self._mtgox2 = MtGoxInterface(access, secret) 69 | 70 | def login(self): 71 | pass 72 | 73 | def request_ticker(self): 74 | url = 'http://data.mtgox.com/api/2/BTCUSD/money/ticker' 75 | response = urllib2.urlopen(url, timeout=10) 76 | ticker_data = json.loads(response.read())['data'] 77 | ticker = Ticker(float(ticker_data['buy']['value']), float(ticker_data['sell']['value']), float(ticker_data['last']['value'])) 78 | return ticker 79 | 80 | def request_info(self): 81 | self.login() 82 | ticker = self.request_ticker() 83 | ai = self._mtgox2.get_account_info() 84 | account_info = AccountInfo('mtgox', ticker, 85 | self.trade_fee, 86 | float(ai['Wallets']['USD']['Balance']['value']), 87 | float(ai['Wallets']['BTC']['Balance']['value']), 88 | '12321321312') 89 | return account_info 90 | 91 | def buy(self, stock_qty, price): 92 | pass 93 | 94 | def sell(self, stock_qty, price): 95 | pass 96 | 97 | def withdraw_stock(self, amount): 98 | params = {'address': None, 'amount_int': int(amount * 1e8), 'fee_int': 0} 99 | r = self.request('money/bitcoin/send_simple', ) 100 | return r 101 | -------------------------------------------------------------------------------- /exchanges/okcoin.py: -------------------------------------------------------------------------------- 1 | #encoding: utf-8 2 | 3 | import json 4 | import httplib 5 | import datetime 6 | import cookielib 7 | import urllib, urllib2 8 | import random 9 | import logging 10 | import os 11 | import lxml.html 12 | import lxml.etree 13 | 14 | from brickmover import config 15 | from brickmover.info import * 16 | 17 | _logger = logging.getLogger('exchanges') 18 | 19 | class OKCoinBtcDepositParser(): 20 | def __init__(self): 21 | self.btc_deposit_address_path = '//div[@class="fincoinaddress-1"]/span' 22 | self.money_balance_path = '//div[@class="accountinfo1"]/div/ul/li[2]/span[2]' 23 | self.btc_balance_path = '//div[@class="accountinfo1"]/div/ul/li[3]/span[2]' 24 | self.btc_deposit_address = None 25 | self.money_balance = 0.0 26 | self.btc_balance = 0.0 27 | self.soup = None 28 | 29 | def parse(self, html): 30 | tree = lxml.html.document_fromstring(html) 31 | self.btc_deposit_address = tree.xpath(self.btc_deposit_address_path)[0].text 32 | self.money_balance = float(tree.xpath(self.money_balance_path)[0].text) 33 | self.btc_balance = float(tree.xpath(self.btc_balance_path)[0].text) 34 | 35 | 36 | class OKCoinExchange: 37 | Name = 'okcoin' 38 | session_period = 10 39 | HOST = 'www.okcoin.com' 40 | BASE_URL = 'https://' + HOST 41 | 42 | def __init__(self, cfg): 43 | self._config = cfg 44 | self.can_withdraw_stock_to_address = False 45 | self.stock_withdraw_fee = 0.0001 46 | self.trade_fee = self._config['trade_fee'] 47 | self._last_logged_time = None 48 | self.cookie_file = os.path.join(config.configuration['data_path'], 'okcoin.cookies') 49 | self.cookieJar = cookielib.MozillaCookieJar(self.cookie_file) 50 | # user provided username and password 51 | self.username = self._config['user_name'] 52 | self.password = self._config['password'] 53 | self.trade_password = self._config['trade_password'] 54 | # set up opener to handle cookies, redirects etc 55 | self.opener = urllib2.build_opener( 56 | urllib2.HTTPRedirectHandler(), 57 | urllib2.HTTPHandler(debuglevel=0), 58 | urllib2.HTTPSHandler(debuglevel=0), 59 | urllib2.HTTPCookieProcessor(self.cookieJar) 60 | ) 61 | # pretend we're a web browser and not a python script 62 | self.opener.addheaders = [('User-agent', 63 | ('Mozilla/4.0 (compatible; MSIE 10.0; ' 64 | 'Windows NT 5.2; .NET CLR 1.1.4322)')) 65 | ] 66 | 67 | def _make_post_url(self, action): 68 | rnd = int(random.random() * 100) 69 | return OKCoinExchange.BASE_URL + action + '?random=' + str(rnd) 70 | 71 | def _send_sms_code(self, type, withdraw_amount, withdraw_btc_addr, symbol): 72 | ''' 73 | type 的解释: 74 | 1: BTC/LTC提现 75 | 2: 设置提现地址 76 | 3: 人民币提现 77 | ''' 78 | self.login() 79 | url = self._make_post_url('/account/sendMsgCode.do') 80 | params = urllib.urlencode({ 81 | 'type': type, 82 | 'withdrawAmount': withdraw_amount, 83 | 'withdrawBtcAddr': withdraw_btc_addr, 84 | 'symbol':symbol 85 | }) 86 | response = self.opener.open(url, params) 87 | response.close() 88 | 89 | def login(self): 90 | if self._last_logged_time and ((datetime.datetime.now() - self._last_logged_time).total_seconds() < OKCoinExchange.session_period * 60): 91 | return 92 | base_url = 'https://' + OKCoinExchange.HOST 93 | self._last_logged_time = datetime.datetime.now() 94 | 95 | # open the front page of the website to set and save initial cookies 96 | response = self.opener.open(OKCoinExchange.BASE_URL) 97 | self.cookieJar.save() 98 | response.close() 99 | 100 | login_data = urllib.urlencode({ 101 | 'loginName' : self.username, 102 | 'password' : self.password 103 | }) 104 | login_action = '/login/index.do' 105 | login_url = self._make_post_url(login_action) 106 | response = self.opener.open(login_url, login_data) 107 | self.cookieJar.save() 108 | response.close() 109 | 110 | def request_ticker(self): 111 | url = 'https://www.okcoin.com/api/ticker.do' 112 | response = urllib2.urlopen(url, timeout=10) 113 | ticker_data = json.loads(response.read())['ticker'] 114 | ticker = Ticker(float(ticker_data['buy']), float(ticker_data['sell']), float(ticker_data['last'])) 115 | return ticker 116 | 117 | def request_info(self): 118 | _logger.info(u'准备开始请求 okcoin.com 帐号信息') 119 | ticker = self.request_ticker() 120 | self.login() 121 | response = self.opener.open('https://www.okcoin.com/rechargeBtc.do') 122 | parser = OKCoinBtcDepositParser() 123 | html = response.read() 124 | parser.parse(html) 125 | btc_deposit_address = parser.btc_deposit_address 126 | response.close() 127 | #withdraw_btc_addr = '17Ar3q9Bkfz7i6RhTJcobgnYw6gNVfE4JE' 128 | #withdraw_amount = '0.1' 129 | #type = 1 130 | #symbol='btc' 131 | #self._send_sms_code(type, withdraw_amount, withdraw_btc_addr, symbol) 132 | return AccountInfo(OKCoinExchange.Name, ticker, self.trade_fee, parser.money_balance, parser.btc_balance, btc_deposit_address) 133 | 134 | def withdraw_stock(self, address, amount): 135 | trade_data = urllib.urlencode({ 136 | 'withdrawAddr': '', 137 | 'withdrawAmount': amount, 138 | 'tradePwd': self.trade_password, 139 | 'validateCode': '', 140 | 'symbol': 0, 141 | }) 142 | action = '/account/withdrawBtcSubmit.do' 143 | url = self._make_post_url(action) 144 | response = self.opener.open(url, trade_data) 145 | response.close() 146 | 147 | def buy(self, stock_qty, price): 148 | trade_data = urllib.urlencode({ 149 | 'tradeAmount': stock_qty, 150 | 'tradeCnyPrice': price, 151 | 'tradePwd': self.trade_password, 152 | 'symbol': 0, 153 | }) 154 | action = '/trade/buyBtcSubmit.do' 155 | url = self._make_post_url(action) 156 | response = self.opener.open(url, trade_data) 157 | response.close() 158 | 159 | def sell(self, stock_qty, price): 160 | trade_data = urllib.urlencode({ 161 | 'tradeAmount': stock_qty, 162 | 'tradeCnyPrice': price, 163 | 'tradePwd': self.trade_password, 164 | 'symbol': 0, 165 | }) 166 | action = '/trade/sellBtcSubmit.do' 167 | url = self._make_post_url(action) 168 | response = self.opener.open(url, trade_data) 169 | response.close() 170 | -------------------------------------------------------------------------------- /info.py: -------------------------------------------------------------------------------- 1 | #encoding: utf-8 2 | 3 | class Ticker: 4 | def __init__(self, buy_price, sell_price, last_price): 5 | self.buy_price = buy_price 6 | self.sell_price = sell_price 7 | self.last_price = last_price 8 | 9 | def __str__(self): 10 | s = str({ 11 | 'buy_price': self.buy_price, 12 | 'sell_price': self.sell_price, 13 | 'last_price': self.last_price 14 | }) 15 | return s 16 | 17 | 18 | class AccountInfo: 19 | def __init__(self, 20 | name, ticker, trade_fee, 21 | money_balance, stock_balance, stock_deposit_address): 22 | assert name 23 | assert ticker 24 | self.name = name 25 | self.ticker = ticker 26 | self.trade_fee = trade_fee 27 | self.money_balance = money_balance 28 | self.stock_balance = stock_balance 29 | self.stock_deposit_address = stock_deposit_address 30 | 31 | 32 | class Suggestion: 33 | def __init__(self, can_go, buy_account, sell_account, buy_price, sell_price, stock_qty): 34 | self.buy_account = buy_account 35 | self.sell_account = sell_account 36 | self.buy_price = buy_price 37 | self.sell_price = sell_price 38 | self.stock_qty = stock_qty 39 | self.can_go = can_go 40 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/bin/python 2 | #encoding: utf-8 3 | 4 | import os, sys 5 | import time 6 | import datetime 7 | import multiprocessing as mp 8 | import logging 9 | 10 | sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 11 | import config 12 | 13 | from info import * 14 | import models 15 | from advisor import Advisor 16 | from wallet import Wallet 17 | import exchanges 18 | 19 | _logger = logging.getLogger(__name__) 20 | 21 | class Trader: 22 | _exchanges = exchanges.actived_exchanges 23 | 24 | def __init__(self): 25 | self.current_order = None 26 | self.wallet = Wallet() 27 | self.qty_per_order = config.configuration['qty_per_order'] 28 | 29 | def trade(self, suggest): 30 | self.current_suggestion = suggest 31 | self.current_order = self._create_order() 32 | self._make_orders(), 33 | 34 | def _create_order(self): 35 | s = self.current_suggestion 36 | session = models.Session() 37 | order = models.Order(s.buy_account.name, s.buy_price, 38 | s.sell_account.name, s.sell_price, s.stock_qty) 39 | try: 40 | session.add(order) 41 | session.commit() 42 | return order 43 | except Exception as e: 44 | _logger.error(e) 45 | session.rollback() 46 | return None 47 | 48 | def _make_orders(self): 49 | '''下买卖委托单''' 50 | #TODO 并行化处理 51 | self._make_sell_order() 52 | self._make_buy_order() 53 | self._check_order_state() 54 | 55 | def _check_order_state(self): 56 | session = models.Session() 57 | try: 58 | if self.current_order.is_bought and self.current_order.is_sold: 59 | self.current_order.state = 'done' 60 | _logger.info('[TRADER] The order is done!') 61 | session.commit() 62 | except Exception as e: 63 | _logger.error(e) 64 | session.rollback() 65 | 66 | 67 | def _make_buy_order(self): 68 | buy_ex = Trader._exchanges[self.current_suggestion.buy_account.name] 69 | session = models.Session() 70 | try: 71 | #buy_ex.buy(self.current_suggestion.stock_qty, self.current_suggestion.buy_price) 72 | self.current_order.bought_time = datetime.datetime.now() 73 | self.current_order.is_bought = True 74 | session.commit() 75 | _logger.info('Buy Order made: {0}'.format(self.current_order.bought_time)) 76 | except Exception as e: 77 | _logger.error(e) 78 | session.rollback() 79 | 80 | def _make_sell_order(self): 81 | sell_ex = Trader._exchanges[self.current_suggestion.sell_account.name] 82 | session = models.Session() 83 | try: 84 | #sell_ex.sell(self.current_suggestion.stock_qty, self.current_suggestion.sell_price) 85 | self.current_order.sold_time = datetime.datetime.now() 86 | self.current_order.is_sold = True 87 | session.commit() 88 | _logger.info('Sell Order made: {0}'.format(self.current_order.sold_time)) 89 | except Exception as e: 90 | _logger.error(e) 91 | session.rollback() 92 | 93 | 94 | def _wait_balance(self): 95 | _logger.info('Waitig for balance...') 96 | time.sleep(1) 97 | 98 | def _wait_sms(self): 99 | pass 100 | 101 | 102 | class Cashier: 103 | _exchanges = exchanges.actived_exchanges 104 | 105 | def __init__(self): 106 | self.wallet = Wallet() 107 | self.qty_per_order = config.configuration['qty_per_order'] 108 | 109 | def post_transfers(self, buy_account, sell_account): 110 | '''交易完成以后的比特币转账 111 | 流程: 112 | 1. 检查钱包是否有足够余额 113 | 2.1 有余额则先发送比特币给卖方 114 | 2. 买方转移比特币到钱包 115 | ''' 116 | buy_ex = Trader._exchanges[buy_account.name] 117 | sell_ex = Trader._exchanges[sell_account.name] 118 | wallet_balance = self.wallet.balance() 119 | if wallet_balance > self.qty_per_order: 120 | self.wallet.withdraw(sell_account.stock_deposit_address, self.qty_per_order) 121 | buy_ex.withdraw_stock(self.qty_per_order) 122 | 123 | def make_balance(self, accounts): 124 | wallet_balance = self.wallet.balance() 125 | for a in accounts: 126 | if a.stock_balance < self.qty_per_order and wallet_balance > self.qty_per_order: 127 | _logger.info('[CASHIER]\t\t Transfering BTC from wallet to account "{0}", qty={1}' 128 | .format(a.name, self.qty_per_order)) 129 | self.wallet.withdraw(a.stock_deposit_address, self.qty_per_order) 130 | wallet_balance -= self.qty_per_order 131 | 132 | 133 | def wait_event(event, seconds): 134 | for i in xrange(0, seconds): 135 | if event.is_set(): 136 | break 137 | time.sleep(1) 138 | 139 | def main_loop(stop_event): 140 | the_advisor = Advisor() 141 | trader = Trader() 142 | cashier = Cashier() 143 | 144 | try: 145 | while not stop_event.is_set(): 146 | _logger.info(u'--------------------------- 交易处理开始 ---------------------------') 147 | accounts = the_advisor.request_accounts_info() 148 | 149 | #cashier.make_balance(accounts) 150 | 151 | s = the_advisor.evaluate(accounts) 152 | if stop_event.is_set(): 153 | break; 154 | if s != None and s.can_go: 155 | print 'trader()' 156 | trader.trade(s) 157 | print 'post_transfers()' 158 | #trader.post_transfers() 159 | print '\n' 160 | else: 161 | _logger.info(u'交易条件不具备,等上一会儿....') 162 | wait_event(stop_event, 60) 163 | except Exception as e: 164 | print e 165 | pass 166 | finally: 167 | the_advisor.close() 168 | 169 | 170 | if __name__ == '__main__': 171 | mp.freeze_support() 172 | print 'The Most Awesome Automatic Arbitrage Bot for BitCoin Exchanges' 173 | print 'Written By Wei Li ' 174 | print 'It is the time to make some money!' 175 | print '\n' 176 | 177 | stop_main_event = mp.Event() 178 | main_loop_process = mp.Process(target=main_loop, args=(stop_main_event,)) 179 | main_loop_process.start() 180 | cmd = '' 181 | while cmd != 'quit': 182 | cmd = raw_input("Type 'quit' to end: ") 183 | _logger.info('Preparing to stop main loop process...') 184 | stop_main_event.set() 185 | main_loop_process.join() 186 | _logger.info(u'程序已经成功停止') 187 | print 'All done. Bye.' 188 | -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | #encoding: utf-8 2 | 3 | import os 4 | import datetime 5 | 6 | from sqlalchemy import * 7 | from sqlalchemy.ext.declarative import declarative_base 8 | from sqlalchemy.orm import relation, sessionmaker 9 | 10 | import config 11 | 12 | BaseModel = declarative_base() 13 | 14 | 15 | class SmsMessage(BaseModel): 16 | __tablename__ = 'sms_messages' 17 | id = Column(Integer, primary_key=True) 18 | arrived_time = Column(DateTime, nullable=False) 19 | mobile = Column(String(32), nullable=True) 20 | content = Column(String(256), nullable=True) 21 | 22 | def __repr__(self): 23 | return "SmsMessage(%r, %r, %r)" % (self.arrived_time, self.mobile, self.content) 24 | 25 | 26 | class TradeLead(BaseModel): 27 | __tablename__ = 'trade_leads' 28 | id = Column(Integer, primary_key=True) 29 | created_time = Column(DateTime, nullable=False) 30 | exchange_to_buy = Column(String(32), nullable=False) 31 | exchange_to_sell = Column(String(32), nullable=False) 32 | buy_price = Column(Float, nullable=False) 33 | sell_price = Column(Float, nullable=False) 34 | 35 | def __init__(self, buy_exchange, buy_price, sell_exchange, sell_price): 36 | self.created_time = datetime.datetime.now() 37 | self.exchange_to_buy = buy_exchange 38 | self.buy_price = buy_price 39 | self.exchange_to_sell = sell_exchange 40 | self.sell_price = sell_price 41 | 42 | @staticmethod 43 | def last(): 44 | session = Session() 45 | o = session.query(TradeLead).order_by(TradeLead.created_time.desc()).first() 46 | return o 47 | 48 | 49 | class Order(BaseModel): 50 | __tablename__ = 'orders' 51 | id = Column(Integer, primary_key=True) 52 | created_time = Column(DateTime, nullable=False) 53 | sold_time = Column(DateTime, nullable=True) 54 | bought_time = Column(DateTime, nullable=True) 55 | is_bought = Column(Boolean, nullable=False) 56 | is_sold = Column(Boolean, nullable=False) 57 | exchange_to_buy = Column(String(32), nullable=False) 58 | exchange_to_sell = Column(String(32), nullable=False) 59 | buy_price = Column(Float, nullable=False) 60 | sell_price = Column(Float, nullable=False) 61 | quantity = Column(Float, nullable=False) 62 | state = Column(Enum('processing', 'done', 'cancel', 'except'), nullable=False) 63 | #director = relation("Director", backref='movies', lazy=False) 64 | 65 | def __init__(self, buy_exchange, buy_price, sell_exchange, sell_price, qty): 66 | self.created_time = datetime.datetime.now() 67 | self.is_sold = False 68 | self.is_bought = False 69 | self.exchange_to_buy = buy_exchange 70 | self.buy_price = buy_price 71 | self.exchange_to_sell = sell_exchange 72 | self.sell_price = sell_price 73 | self.quantity = qty 74 | self.state = 'processing' 75 | 76 | def __repr__(self): 77 | return "Order(%r, %r, %r, %r, %r, %r)" % (self.id, self.created_time, self.buy_price, self.sell_price, self.quantity, self.state) 78 | 79 | @staticmethod 80 | def last(): 81 | session = Session() 82 | o = session.query(Order).order_by(Order.id.desc()).first() 83 | return o 84 | 85 | db_path = os.path.join(config.configuration['data_path'], 'brickmover.db') 86 | engine = create_engine('sqlite:///' + db_path) 87 | BaseModel.metadata.create_all(engine) 88 | Session = sessionmaker(bind=engine) 89 | 90 | ''' 91 | session = Session() 92 | 93 | m1 = Movie("Star Trek", 2009) 94 | m1.director = Director("JJ Abrams") 95 | 96 | d2 = Director("George Lucas") 97 | d2.movies = [Movie("Star Wars", 1977), Movie("THX 1138", 1971)] 98 | 99 | try: 100 | session.add(m1) 101 | session.add(d2) 102 | session.commit() 103 | except: 104 | session.rollback() 105 | 106 | alldata = session.query(Movie).all() 107 | for somedata in alldata: 108 | print somedata 109 | ''' 110 | -------------------------------------------------------------------------------- /pygsm/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) UNICEF and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are 6 | met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in 13 | the documentation and/or other materials provided with the 14 | distribution. 15 | 16 | 3. Neither the name of UNICEF nor the names of its contributors 17 | may be used to endorse or promote products derived from this 18 | software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS 21 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED 22 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 23 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER 24 | OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /pygsm/README: -------------------------------------------------------------------------------- 1 | Help on class GsmModem in module pygsm.gsmmodem: 2 | 3 | class GsmModem(__builtin__.object) 4 | | 5 | | pyGSM is a Python module which uses pySerial to provide a nifty 6 | | interface to send and receive SMS via a GSM Modem. It was ported 7 | | from RubyGSM, and provides (almost) all of the same features. It's 8 | | easy to get started: 9 | | 10 | | # create a GsmModem object: 11 | | >>> import pygsm 12 | | >>> modem = pygsm.GsmModem(port="/dev/ttyUSB0") 13 | | 14 | | # harass Evan over SMS: 15 | | # (try to do this before 11AM) 16 | | >>> modem.send_sms("+13364130840", "Hey, wake up!") 17 | | 18 | | # check for incoming SMS: 19 | | >>> print modem.next_message() 20 | | 21 | | 22 | | 23 | | There are various ways of polling for incoming messages -- a choice 24 | | which has been deliberately left to the application author (unlike 25 | | RubyGSM). Execute `python -m pygsm.gsmmodem` to run this example: 26 | | 27 | | # connect to the modem 28 | | modem = pygsm.GsmModem(port=sys.argv[1]) 29 | | 30 | | # check for new messages every two 31 | | # seconds for the rest of forever 32 | | while True: 33 | | msg = modem.next_message() 34 | | 35 | | # we got a message! respond with 36 | | # something useless, as an example 37 | | if msg is not None: 38 | | msg.respond("Thanks for those %d characters!" % 39 | | len(msg.text)) 40 | | 41 | | # no messages? wait a couple 42 | | # of seconds and try again 43 | | else: time.sleep(2) 44 | | 45 | | 46 | | pyGSM is distributed via GitHub: 47 | | http://github.com/adammck/pygsm 48 | | 49 | | Bugs reports (especially for 50 | | unsupported devices) are welcome: 51 | | http://github.com/adammck/pygsm/issues 52 | | 53 | | 54 | | 55 | | 56 | | Methods defined here: 57 | | 58 | | __init__(self, *args, **kwargs) 59 | | Creates, connects to, and boots a GSM Modem. All of the arguments 60 | | are optional (although "port=" should almost always be provided), 61 | | and passed along to serial.Serial.__init__ verbatim. For all of 62 | | the possible configration options, see: 63 | | 64 | | http://pyserial.wiki.sourceforge.net/pySerial#tocpySerial10 65 | | 66 | | boot(self, reboot=False) 67 | | Initializes the modem. Must be called after init and connect, 68 | | but before doing anything that expects the modem to be ready. 69 | | 70 | | command(self, cmd, read_term=None, read_timeout=None, write_term='\r') 71 | | Issue a single AT command to the modem, and return the sanitized 72 | | response. Sanitization removes status notifications, command echo, 73 | | and incoming messages, (hopefully) leaving only the actual response 74 | | from the command. 75 | | 76 | | connect(self, reconnect=False) 77 | | Creates the connection to the modem via pySerial, optionally 78 | | killing and re-creating any existing connection. 79 | | 80 | | disconnect(self) 81 | | Disconnects from the modem. 82 | | 83 | | hardware(self) 84 | | Returns a dict of containing information about the physical 85 | | modem. The contents of each value are entirely manufacturer 86 | | dependant, and vary wildly between devices. 87 | | 88 | | next_message(self, fetch=True) 89 | | Returns the next waiting IncomingMessage object, or None if 90 | | the queue is empty. The optional _fetch_ parameter controls 91 | | whether the modem is polled before checking, which can be 92 | | disabled in case you're polling in a separate thread. 93 | | 94 | | ping(self) 95 | | Sends the "AT" command to the device, and returns true 96 | | if it is acknowledged. Since incoming notifications and 97 | | messages are intercepted automatically, this is a good 98 | | way to poll for new messages without using a worker 99 | | thread like RubyGSM. 100 | | 101 | | query(self, cmd) 102 | | Issues a single AT command to the modem, and returns the relevant 103 | | part of the response. This only works for commands that return a 104 | | single line followed by "OK", but conveniently, this covers almost 105 | | all AT commands that I've ever needed to use. 106 | | 107 | | For all other commands, returns None. 108 | | 109 | | send_sms(self, recipient, text) 110 | | Sends an SMS to _recipient_ containing _text_. Some networks 111 | | will automatically chunk long messages into multiple parts, 112 | | and reassembled them upon delivery, but some will silently 113 | | drop them. At the moment, pyGSM does nothing to avoid this, 114 | | so try to keep _text_ under 160 characters. 115 | | 116 | | signal_strength(self) 117 | | Returns an integer between 1 and 99, representing the current 118 | | signal strength of the GSM network, False if we don't know, or 119 | | None if the modem can't report it. 120 | | 121 | | wait_for_network(self) 122 | | Blocks until the signal strength indicates that the 123 | | device is active on the GSM network. It's a good idea 124 | | to call this before trying to send or receive anything. 125 | -------------------------------------------------------------------------------- /pygsm/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4i encoding=utf-8 3 | 4 | 5 | from gsmmodem import GsmModem 6 | __doc__ = GsmModem.__doc__ 7 | -------------------------------------------------------------------------------- /pygsm/devicewrapper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 encoding=utf-8 3 | 4 | # arch: pacman -S python-pyserial 5 | # debian/ubuntu: apt-get install python-serial 6 | import serial 7 | import re 8 | import errors 9 | 10 | class DeviceWrapper(object): 11 | 12 | def __init__(self, logger, *args, **kwargs): 13 | 14 | # Sanitize arguments before sending to pySerial 15 | 16 | # force cast strings from the .ini (which show up 17 | # in kwargs) to ints because strings seem to make 18 | # pySerial on Windows unhappy 19 | 20 | for key in ['baudrate', 21 | 'xonxoff', 22 | 'rtscts', 23 | 'stopbits', 24 | 'timeout' 25 | ]: 26 | if key in kwargs: 27 | try: 28 | kwargs[key] = int(kwargs[key]) 29 | except: 30 | # not a valid value, just remove 31 | kwargs.pop(key) 32 | 33 | self.device = serial.Serial(*args, **kwargs) 34 | self.logger = logger 35 | 36 | def isOpen(self): 37 | return self.device.isOpen() 38 | 39 | def close(self): 40 | self.device.close() 41 | 42 | def write(self, str): 43 | self.device.write(str) 44 | 45 | def _read(self, read_term=None, read_timeout=None): 46 | """Read from the modem (blocking) until _terminator_ is hit, 47 | (defaults to \r\n, which reads a single "line"), and return.""" 48 | 49 | buffer = [] 50 | 51 | # if a different timeout was requested just 52 | # for _this_ read, store and override the 53 | # current device setting (not thread safe!) 54 | if read_timeout is not None: 55 | old_timeout = self.device.timeout 56 | self.device.timeout = read_timeout 57 | 58 | def __reset_timeout(): 59 | """restore the device's previous timeout 60 | setting, if we overrode it earlier.""" 61 | if read_timeout is not None: 62 | self.device.timeout =\ 63 | old_timeout 64 | 65 | # the default terminator reads 66 | # until a newline is hit 67 | if read_term is None: 68 | read_term = "\r\n" 69 | 70 | while(True): 71 | buf = self.device.read() 72 | buffer.append(buf) 73 | # if a timeout was hit, raise an exception including the raw data that 74 | # we've already read (in case the calling func was _expecting_ a timeout 75 | # (wouldn't it be nice if serial.Serial.read returned None for this?) 76 | if buf == '': 77 | __reset_timeout() 78 | raise(errors.GsmReadTimeoutError(buffer)) 79 | 80 | # if last n characters of the buffer match the read 81 | # terminator, return what we've received so far 82 | if ''.join(buffer[-len(read_term):]) == read_term: 83 | buf_str = ''.join(buffer) 84 | __reset_timeout() 85 | 86 | self._log(repr(buf_str), 'read') 87 | return buf_str 88 | 89 | 90 | def read_lines(self, read_term=None, read_timeout=None): 91 | """Read from the modem (blocking) one line at a time until a response 92 | terminator ("OK", "ERROR", or "CMx ERROR...") is hit, then return 93 | a list containing the lines.""" 94 | buffer = [] 95 | 96 | # keep on looping until a command terminator 97 | # is encountered. these are NOT the same as the 98 | # "read_term" argument - only OK or ERROR is valid 99 | while(True): 100 | buf = self._read( 101 | read_term=read_term, 102 | read_timeout=read_timeout) 103 | 104 | buf = buf.strip() 105 | buffer.append(buf) 106 | 107 | # most commands return OK for success, but there 108 | # are some exceptions. we're not checking those 109 | # here (unlike RubyGSM), because they should be 110 | # handled when they're _expected_ 111 | if buf == "OK": 112 | return buffer 113 | 114 | # some errors contain useful error codes, so raise a 115 | # proper error with a description from pygsm/errors.py 116 | m = re.match(r"^\+(CM[ES]) ERROR: (\d+)$", buf) 117 | if m is not None: 118 | type, code = m.groups() 119 | raise(errors.GsmModemError(type, int(code))) 120 | 121 | # ...some errors are not so useful 122 | # (at+cmee=1 should enable error codes) 123 | if buf == "ERROR": 124 | raise(errors.GsmModemError) 125 | 126 | def _log(self, str_, type_="debug"): 127 | if hasattr(self, "logger"): 128 | self.logger(self, str_, type_) 129 | 130 | -------------------------------------------------------------------------------- /pygsm/errors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 3 | 4 | 5 | import serial 6 | 7 | 8 | class GsmError(serial.SerialException): 9 | pass 10 | 11 | 12 | class GsmIOError(GsmError): 13 | pass 14 | 15 | 16 | class GsmWriteError(GsmIOError): 17 | pass 18 | 19 | 20 | class GsmReadError(GsmIOError): 21 | pass 22 | 23 | 24 | class GsmReadTimeoutError(GsmReadError): 25 | def __init__(self, pending_data): 26 | self.pending_data = pending_data 27 | 28 | 29 | class GsmModemError(GsmError): 30 | STRINGS = { 31 | "CME": { 32 | 3: "Operation not allowed", 33 | 4: "Operation not supported", 34 | 5: "PH-SIM PIN required (SIM lock)", 35 | 10: "SIM not inserted", 36 | 11: "SIM PIN required", 37 | 12: "SIM PUK required", 38 | 13: "SIM failure", 39 | 16: "Incorrect password", 40 | 17: "SIM PIN2 required", 41 | 18: "SIM PUK2 required", 42 | 20: "Memory full", 43 | 21: "Invalid index", 44 | 22: "Not found", 45 | 24: "Text string too long", 46 | 26: "Dial string too long", 47 | 27: "Invalid characters in dial string", 48 | 30: "No network service", 49 | 32: "Network not allowed. Emergency calls only", 50 | 40: "Network personal PIN required (Network lock)", 51 | 103: "Illegal MS (#3)", 52 | 106: "Illegal ME (#6)", 53 | 107: "GPRS services not allowed", 54 | 111: "PLMN not allowed", 55 | 112: "Location area not allowed", 56 | 113: "Roaming not allowed in this area", 57 | 132: "Service option not supported", 58 | 133: "Requested service option not subscribed", 59 | 134: "Service option temporarily out of order", 60 | 148: "unspecified GPRS error", 61 | 149: "PDP authentication failure", 62 | 150: "Invalid mobile class" }, 63 | "CMS": { 64 | 021: "Call Rejected (out of credit?)", 65 | 301: "SMS service of ME reserved", 66 | 302: "Operation not allowed", 67 | 303: "Operation not supported", 68 | 304: "Invalid PDU mode parameter", 69 | 305: "Invalid text mode parameter", 70 | 310: "SIM not inserted", 71 | 311: "SIM PIN required", 72 | 312: "PH-SIM PIN required", 73 | 313: "SIM failure", 74 | 316: "SIM PUK required", 75 | 317: "SIM PIN2 required", 76 | 318: "SIM PUK2 required", 77 | 321: "Invalid memory index", 78 | 322: "SIM memory full", 79 | 330: "SC address unknown", 80 | 340: "No +CNMA acknowledgement expected", 81 | 500: "Unknown error", 82 | 512: "MM establishment failure (for SMS)", 83 | 513: "Lower layer failure (for SMS)", 84 | 514: "CP error (for SMS)", 85 | 515: "Please wait, init or command processing in progress", 86 | 517: "SIM Toolkit facility not supported", 87 | 518: "SIM Toolkit indication not received", 88 | 519: "Reset product to activate or change new echo cancellation algo", 89 | 520: "Automatic abort about get PLMN list for an incomming call", 90 | 526: "PIN deactivation forbidden with this SIM card", 91 | 527: "Please wait, RR or MM is busy. Retry your selection later", 92 | 528: "Location update failure. Emergency calls only", 93 | 529: "PLMN selection failure. Emergency calls only", 94 | 531: "SMS not send: the is not in FDN phonebook, and FDN lock is enabled (for SMS)" }} 95 | 96 | def __init__(self, type=None, code=None): 97 | self.type = type 98 | self.code = code 99 | 100 | def __str__(self): 101 | if self.type and self.code: 102 | return "%s ERROR %d: %s" % ( 103 | self.type, self.code, 104 | self.STRINGS[self.type][self.code]) 105 | 106 | # no type and/or code were provided 107 | else: return "Unknown GSM Error" 108 | -------------------------------------------------------------------------------- /pygsm/gsmcodecs/__init__.py: -------------------------------------------------------------------------------- 1 | # vim: ai ts=4 sts=4 et sw=4 encoding=utf-8 2 | 3 | # 4 | # Codec registry functions 5 | # 6 | 7 | import codecs 8 | import gsm0338 9 | 10 | #constants 11 | ALIASES=['gsm','gsm0338'] 12 | GSM_CODEC_ENTRY='gsm_codec_entry' 13 | 14 | #module globals 15 | _G={ 16 | GSM_CODEC_ENTRY:None 17 | } 18 | 19 | def search_function(encoding): 20 | if encoding not in ALIASES: 21 | return None 22 | 23 | if _G[GSM_CODEC_ENTRY] is None: 24 | _G[GSM_CODEC_ENTRY]=gsm0338.getregentry() 25 | 26 | return _G[GSM_CODEC_ENTRY] 27 | 28 | codecs.register(search_function) 29 | -------------------------------------------------------------------------------- /pygsm/gsmcodecs/gsm0338.py: -------------------------------------------------------------------------------- 1 | # vim: ai ts=4 sts=4 et sw=4 encoding=utf-8 2 | 3 | """ Python Character Mapping Codec based on gsm0338 generated from './GSM0338.TXT' with gencodec.py. 4 | 5 | With extra sauce to deal with the 'multibyte' extensions! 6 | 7 | """#" 8 | 9 | import codecs 10 | import re 11 | 12 | ### Codec APIs 13 | 14 | # 15 | # Shared funcs 16 | # 17 | def _encode(input,errors='strict'): 18 | # split to see if we have any 'extended' characters 19 | runs=unicode_splitter.split(input) 20 | 21 | # now iterate through handling any 'multibyte' ourselves 22 | out_str=list() 23 | consumed=0 24 | extended=extended_encode_map.keys() 25 | for run in runs: 26 | if len(run)==1 and run[0] in extended: 27 | out_str.append(extended_indicator+extended_encode_map[run]) 28 | consumed+=1 29 | else: 30 | # pass it to the standard encoder 31 | out,cons=codecs.charmap_encode(run,errors,encoding_table) 32 | out_str.append(out) 33 | consumed+=cons 34 | return (''.join(out_str),consumed) 35 | 36 | def _decode(input,errors='strict'): 37 | # opposite of above, look for multibye 'marker' 38 | # and handle it ourselves, pass the rest to the 39 | # standard decoder 40 | 41 | # split to see if we have any 'extended' characters 42 | runs = str_splitter.split(input) 43 | 44 | # now iterate through handling any 'multibyte' ourselves 45 | out_uni = [] 46 | consumed = 0 47 | for run in runs: 48 | if len(run)==0: 49 | # first char was a marker, but we don't care 50 | # the marker itself will come up in the next run 51 | continue 52 | if len(run)==2 and run[0]==extended_indicator: 53 | try: 54 | out_uni.append(extended_decode_map[run[1]]) 55 | consumed += 2 56 | continue 57 | except KeyError: 58 | # second char was not an extended, so 59 | # let this pass through and the marker 60 | # will be interpreted by the table as a NBSP 61 | pass 62 | 63 | # pass it to the standard encoder 64 | out,cons=codecs.charmap_decode(run,errors,decoding_table) 65 | out_uni.append(out) 66 | consumed+=cons 67 | return (u''.join(out_uni),consumed) 68 | 69 | 70 | class Codec(codecs.Codec): 71 | def encode(self,input,errors='strict'): 72 | return _encode(input,errors) 73 | 74 | def decode(self,input,errors='strict'): 75 | # strip any trailing '\x00's as the standard 76 | # says trailing ones are _not_ @'s and 77 | # are in fact blanks 78 | if input[-1]=='\x00': 79 | input=input[:-1] 80 | return _decode(input,errors) 81 | 82 | class IncrementalEncoder(codecs.IncrementalEncoder): 83 | def encode(self, input, final=False): 84 | # just use the standard encoding as there is no need 85 | # to hold state 86 | return _encode(input,self.errors)[0] 87 | 88 | class IncrementalDecoder(codecs.IncrementalDecoder): 89 | # a little trickier 'cause input _might_ come in 90 | # split right on the extended char marker boundary 91 | def __init__(self,errors='strict'): 92 | codecs.IncrementalDecoder.__init__(self,errors) 93 | self.last_saw_mark=False 94 | 95 | def decode(self, input, final=False): 96 | if final: 97 | # check for final '\x00' which should not 98 | # be interpreted as a '@' 99 | if input[-1]=='\x00': 100 | input=input[:-1] 101 | 102 | # keep track of how many chars we've added or 103 | # removed to the run to adjust the response from 104 | # _decode 105 | consumed_delta=0 106 | # see if last char was a 2-byte mark 107 | if self.last_saw_mark: 108 | # add it back to the current run 109 | input=extended_indicator+input 110 | consumed_delta-=1 # 'cause we added a char 111 | self.last_saw_mark=False # reset 112 | if input[-1:]==extended_indicator and not final: 113 | # chop it off 114 | input=input[:-1] 115 | consumed_delta+=1 # because we just consumed one char 116 | self.last_saw_mark=True 117 | 118 | # NOTE: if we are final and last mark is 119 | # and extended indicator, it will be interpreted 120 | # as NBSP 121 | return _decode(input,self.errors)[0] 122 | 123 | def reset(self): 124 | self.last_saw_mark=False 125 | 126 | class StreamWriter(Codec,codecs.StreamWriter): 127 | pass 128 | 129 | class StreamReader(Codec,codecs.StreamReader): 130 | pass 131 | 132 | ### encodings module API 133 | 134 | def getregentry(): 135 | return codecs.CodecInfo( 136 | name='gsm0338', 137 | encode=Codec().encode, 138 | decode=Codec().decode, 139 | incrementalencoder=IncrementalEncoder, 140 | incrementaldecoder=IncrementalDecoder, 141 | streamreader=StreamReader, 142 | streamwriter=StreamWriter, 143 | ) 144 | 145 | 146 | ### Decoding Tables 147 | 148 | # gsm 'extended' character. 149 | # gsm, annoyingly, is MOSTLY 7-bit chars 150 | # 151 | # BUT has 10 'extended' chars represented 152 | # by 2-chars, an idicator, and then one of 153 | # the 10 154 | 155 | # first of the 2-chars is indicator 156 | extended_indicator='\x1b' 157 | 158 | # second char is the 'extended' character 159 | extended_encode_map = { # Unicode->GSM string 160 | u'\x0c':'\x0a', # FORM FEED 161 | u'^':'\x14', # CIRCUMFLEX ACCENT 162 | u'{':'\x28', # LEFT CURLY BRACKET 163 | u'}':'\x29', # RIGHT CURLY BRACKET 164 | u'\\':'\x2f', # REVERSE SOLIDUS 165 | u'[':'\x3c', # LEFT SQUARE BRACKET 166 | u'~':'\x3d', # TILDE 167 | u']':'\x3e', # RIGHT SQUARE BRACKET 168 | u'|':'\x40', # VERTICAL LINE 169 | u'\u20ac':'\x65' # EURO SIGN 170 | } 171 | 172 | # reverse the map above for decoding 173 | # GSM String->Unicode 174 | uni,gsm=zip(*extended_encode_map.items()) 175 | extended_decode_map=dict(zip(gsm,uni)) 176 | 177 | # splitter 178 | str_splitter=re.compile('(%(ind)s[^%(ind)s])' % { 'ind':extended_indicator }) 179 | unicode_splitter=re.compile(u'([%s])' % re.escape(''.join(extended_encode_map.keys())), re.UNICODE) 180 | 181 | # the normal 1-char table 182 | decoding_table = ( 183 | u'@' # 0x00 -> COMMERCIAL AT 184 | u'\xa3' # 0x01 -> POUND SIGN 185 | u'$' # 0x02 -> DOLLAR SIGN 186 | u'\xa5' # 0x03 -> YEN SIGN 187 | u'\xe8' # 0x04 -> LATIN SMALL LETTER E WITH GRAVE 188 | u'\xe9' # 0x05 -> LATIN SMALL LETTER E WITH ACUTE 189 | u'\xf9' # 0x06 -> LATIN SMALL LETTER U WITH GRAVE 190 | u'\xec' # 0x07 -> LATIN SMALL LETTER I WITH GRAVE 191 | u'\xf2' # 0x08 -> LATIN SMALL LETTER O WITH GRAVE 192 | u'\xe7' # 0x09 -> LATIN SMALL LETTER C WITH CEDILLA 193 | u'\n' # 0x0A -> LINE FEED 194 | u'\xd8' # 0x0B -> LATIN CAPITAL LETTER O WITH STROKE 195 | u'\xf8' # 0x0C -> LATIN SMALL LETTER O WITH STROKE 196 | u'\r' # 0x0D -> CARRIAGE RETURN 197 | u'\xc5' # 0x0E -> LATIN CAPITAL LETTER A WITH RING ABOVE 198 | u'\xe5' # 0x0F -> LATIN SMALL LETTER A WITH RING ABOVE 199 | u'\u0394' # 0x10 -> GREEK CAPITAL LETTER DELTA 200 | u'_' # 0x11 -> LOW LINE 201 | u'\u03a6' # 0x12 -> GREEK CAPITAL LETTER PHI 202 | u'\u0393' # 0x13 -> GREEK CAPITAL LETTER GAMMA 203 | u'\u039b' # 0x14 -> GREEK CAPITAL LETTER LAMDA 204 | u'\u03a9' # 0x15 -> GREEK CAPITAL LETTER OMEGA 205 | u'\u03a0' # 0x16 -> GREEK CAPITAL LETTER PI 206 | u'\u03a8' # 0x17 -> GREEK CAPITAL LETTER PSI 207 | u'\u03a3' # 0x18 -> GREEK CAPITAL LETTER SIGMA 208 | u'\u0398' # 0x19 -> GREEK CAPITAL LETTER THETA 209 | u'\u039e' # 0x1A -> GREEK CAPITAL LETTER XI 210 | u'\xa0' # 0x1B -> ESCAPE TO EXTENSION TABLE (or displayed as NBSP, see note above) 211 | u'\xc6' # 0x1C -> LATIN CAPITAL LETTER AE 212 | u'\xe6' # 0x1D -> LATIN SMALL LETTER AE 213 | u'\xdf' # 0x1E -> LATIN SMALL LETTER SHARP S (German) 214 | u'\xc9' # 0x1F -> LATIN CAPITAL LETTER E WITH ACUTE 215 | u' ' # 0x20 -> SPACE 216 | u'!' # 0x21 -> EXCLAMATION MARK 217 | u'"' # 0x22 -> QUOTATION MARK 218 | u'#' # 0x23 -> NUMBER SIGN 219 | u'\xa4' # 0x24 -> CURRENCY SIGN 220 | u'%' # 0x25 -> PERCENT SIGN 221 | u'&' # 0x26 -> AMPERSAND 222 | u"'" # 0x27 -> APOSTROPHE 223 | u'(' # 0x28 -> LEFT PARENTHESIS 224 | u')' # 0x29 -> RIGHT PARENTHESIS 225 | u'*' # 0x2A -> ASTERISK 226 | u'+' # 0x2B -> PLUS SIGN 227 | u',' # 0x2C -> COMMA 228 | u'-' # 0x2D -> HYPHEN-MINUS 229 | u'.' # 0x2E -> FULL STOP 230 | u'/' # 0x2F -> SOLIDUS 231 | u'0' # 0x30 -> DIGIT ZERO 232 | u'1' # 0x31 -> DIGIT ONE 233 | u'2' # 0x32 -> DIGIT TWO 234 | u'3' # 0x33 -> DIGIT THREE 235 | u'4' # 0x34 -> DIGIT FOUR 236 | u'5' # 0x35 -> DIGIT FIVE 237 | u'6' # 0x36 -> DIGIT SIX 238 | u'7' # 0x37 -> DIGIT SEVEN 239 | u'8' # 0x38 -> DIGIT EIGHT 240 | u'9' # 0x39 -> DIGIT NINE 241 | u':' # 0x3A -> COLON 242 | u';' # 0x3B -> SEMICOLON 243 | u'<' # 0x3C -> LESS-THAN SIGN 244 | u'=' # 0x3D -> EQUALS SIGN 245 | u'>' # 0x3E -> GREATER-THAN SIGN 246 | u'?' # 0x3F -> QUESTION MARK 247 | u'\xa1' # 0x40 -> INVERTED EXCLAMATION MARK 248 | u'A' # 0x41 -> LATIN CAPITAL LETTER A 249 | u'B' # 0x42 -> LATIN CAPITAL LETTER B 250 | u'C' # 0x43 -> LATIN CAPITAL LETTER C 251 | u'D' # 0x44 -> LATIN CAPITAL LETTER D 252 | u'E' # 0x45 -> LATIN CAPITAL LETTER E 253 | u'F' # 0x46 -> LATIN CAPITAL LETTER F 254 | u'G' # 0x47 -> LATIN CAPITAL LETTER G 255 | u'H' # 0x48 -> LATIN CAPITAL LETTER H 256 | u'I' # 0x49 -> LATIN CAPITAL LETTER I 257 | u'J' # 0x4A -> LATIN CAPITAL LETTER J 258 | u'K' # 0x4B -> LATIN CAPITAL LETTER K 259 | u'L' # 0x4C -> LATIN CAPITAL LETTER L 260 | u'M' # 0x4D -> LATIN CAPITAL LETTER M 261 | u'N' # 0x4E -> LATIN CAPITAL LETTER N 262 | u'O' # 0x4F -> LATIN CAPITAL LETTER O 263 | u'P' # 0x50 -> LATIN CAPITAL LETTER P 264 | u'Q' # 0x51 -> LATIN CAPITAL LETTER Q 265 | u'R' # 0x52 -> LATIN CAPITAL LETTER R 266 | u'S' # 0x53 -> LATIN CAPITAL LETTER S 267 | u'T' # 0x54 -> LATIN CAPITAL LETTER T 268 | u'U' # 0x55 -> LATIN CAPITAL LETTER U 269 | u'V' # 0x56 -> LATIN CAPITAL LETTER V 270 | u'W' # 0x57 -> LATIN CAPITAL LETTER W 271 | u'X' # 0x58 -> LATIN CAPITAL LETTER X 272 | u'Y' # 0x59 -> LATIN CAPITAL LETTER Y 273 | u'Z' # 0x5A -> LATIN CAPITAL LETTER Z 274 | u'\xc4' # 0x5B -> LATIN CAPITAL LETTER A WITH DIAERESIS 275 | u'\xd6' # 0x5C -> LATIN CAPITAL LETTER O WITH DIAERESIS 276 | u'\xd1' # 0x5D -> LATIN CAPITAL LETTER N WITH TILDE 277 | u'\xdc' # 0x5E -> LATIN CAPITAL LETTER U WITH DIAERESIS 278 | u'\xa7' # 0x5F -> SECTION SIGN 279 | u'\xbf' # 0x60 -> INVERTED QUESTION MARK 280 | u'a' # 0x61 -> LATIN SMALL LETTER A 281 | u'b' # 0x62 -> LATIN SMALL LETTER B 282 | u'c' # 0x63 -> LATIN SMALL LETTER C 283 | u'd' # 0x64 -> LATIN SMALL LETTER D 284 | u'e' # 0x65 -> LATIN SMALL LETTER E 285 | u'f' # 0x66 -> LATIN SMALL LETTER F 286 | u'g' # 0x67 -> LATIN SMALL LETTER G 287 | u'h' # 0x68 -> LATIN SMALL LETTER H 288 | u'i' # 0x69 -> LATIN SMALL LETTER I 289 | u'j' # 0x6A -> LATIN SMALL LETTER J 290 | u'k' # 0x6B -> LATIN SMALL LETTER K 291 | u'l' # 0x6C -> LATIN SMALL LETTER L 292 | u'm' # 0x6D -> LATIN SMALL LETTER M 293 | u'n' # 0x6E -> LATIN SMALL LETTER N 294 | u'o' # 0x6F -> LATIN SMALL LETTER O 295 | u'p' # 0x70 -> LATIN SMALL LETTER P 296 | u'q' # 0x71 -> LATIN SMALL LETTER Q 297 | u'r' # 0x72 -> LATIN SMALL LETTER R 298 | u's' # 0x73 -> LATIN SMALL LETTER S 299 | u't' # 0x74 -> LATIN SMALL LETTER T 300 | u'u' # 0x75 -> LATIN SMALL LETTER U 301 | u'v' # 0x76 -> LATIN SMALL LETTER V 302 | u'w' # 0x77 -> LATIN SMALL LETTER W 303 | u'x' # 0x78 -> LATIN SMALL LETTER X 304 | u'y' # 0x79 -> LATIN SMALL LETTER Y 305 | u'z' # 0x7A -> LATIN SMALL LETTER Z 306 | u'\xe4' # 0x7B -> LATIN SMALL LETTER A WITH DIAERESIS 307 | u'\xf6' # 0x7C -> LATIN SMALL LETTER O WITH DIAERESIS 308 | u'\xf1' # 0x7D -> LATIN SMALL LETTER N WITH TILDE 309 | u'\xfc' # 0x7E -> LATIN SMALL LETTER U WITH DIAERESIS 310 | u'\xe0' # 0x7F -> LATIN SMALL LETTER A WITH GRAVE 311 | u'\ufffe' # 0x80 -> UNDEFINED 312 | u'\ufffe' # 0x81 -> UNDEFINED 313 | u'\ufffe' # 0x82 -> UNDEFINED 314 | u'\ufffe' # 0x83 -> UNDEFINED 315 | u'\ufffe' # 0x84 -> UNDEFINED 316 | u'\ufffe' # 0x85 -> UNDEFINED 317 | u'\ufffe' # 0x86 -> UNDEFINED 318 | u'\ufffe' # 0x87 -> UNDEFINED 319 | u'\ufffe' # 0x88 -> UNDEFINED 320 | u'\ufffe' # 0x89 -> UNDEFINED 321 | u'\ufffe' # 0x8A -> UNDEFINED 322 | u'\ufffe' # 0x8B -> UNDEFINED 323 | u'\ufffe' # 0x8C -> UNDEFINED 324 | u'\ufffe' # 0x8D -> UNDEFINED 325 | u'\ufffe' # 0x8E -> UNDEFINED 326 | u'\ufffe' # 0x8F -> UNDEFINED 327 | u'\ufffe' # 0x90 -> UNDEFINED 328 | u'\ufffe' # 0x91 -> UNDEFINED 329 | u'\ufffe' # 0x92 -> UNDEFINED 330 | u'\ufffe' # 0x93 -> UNDEFINED 331 | u'\ufffe' # 0x94 -> UNDEFINED 332 | u'\ufffe' # 0x95 -> UNDEFINED 333 | u'\ufffe' # 0x96 -> UNDEFINED 334 | u'\ufffe' # 0x97 -> UNDEFINED 335 | u'\ufffe' # 0x98 -> UNDEFINED 336 | u'\ufffe' # 0x99 -> UNDEFINED 337 | u'\ufffe' # 0x9A -> UNDEFINED 338 | u'\ufffe' # 0x9B -> UNDEFINED 339 | u'\ufffe' # 0x9C -> UNDEFINED 340 | u'\ufffe' # 0x9D -> UNDEFINED 341 | u'\ufffe' # 0x9E -> UNDEFINED 342 | u'\ufffe' # 0x9F -> UNDEFINED 343 | u'\ufffe' # 0xA0 -> UNDEFINED 344 | u'\ufffe' # 0xA1 -> UNDEFINED 345 | u'\ufffe' # 0xA2 -> UNDEFINED 346 | u'\ufffe' # 0xA3 -> UNDEFINED 347 | u'\ufffe' # 0xA4 -> UNDEFINED 348 | u'\ufffe' # 0xA5 -> UNDEFINED 349 | u'\ufffe' # 0xA6 -> UNDEFINED 350 | u'\ufffe' # 0xA7 -> UNDEFINED 351 | u'\ufffe' # 0xA8 -> UNDEFINED 352 | u'\ufffe' # 0xA9 -> UNDEFINED 353 | u'\ufffe' # 0xAA -> UNDEFINED 354 | u'\ufffe' # 0xAB -> UNDEFINED 355 | u'\ufffe' # 0xAC -> UNDEFINED 356 | u'\ufffe' # 0xAD -> UNDEFINED 357 | u'\ufffe' # 0xAE -> UNDEFINED 358 | u'\ufffe' # 0xAF -> UNDEFINED 359 | u'\ufffe' # 0xB0 -> UNDEFINED 360 | u'\ufffe' # 0xB1 -> UNDEFINED 361 | u'\ufffe' # 0xB2 -> UNDEFINED 362 | u'\ufffe' # 0xB3 -> UNDEFINED 363 | u'\ufffe' # 0xB4 -> UNDEFINED 364 | u'\ufffe' # 0xB5 -> UNDEFINED 365 | u'\ufffe' # 0xB6 -> UNDEFINED 366 | u'\ufffe' # 0xB7 -> UNDEFINED 367 | u'\ufffe' # 0xB8 -> UNDEFINED 368 | u'\ufffe' # 0xB9 -> UNDEFINED 369 | u'\ufffe' # 0xBA -> UNDEFINED 370 | u'\ufffe' # 0xBB -> UNDEFINED 371 | u'\ufffe' # 0xBC -> UNDEFINED 372 | u'\ufffe' # 0xBD -> UNDEFINED 373 | u'\ufffe' # 0xBE -> UNDEFINED 374 | u'\ufffe' # 0xBF -> UNDEFINED 375 | u'\ufffe' # 0xC0 -> UNDEFINED 376 | u'\ufffe' # 0xC1 -> UNDEFINED 377 | u'\ufffe' # 0xC2 -> UNDEFINED 378 | u'\ufffe' # 0xC3 -> UNDEFINED 379 | u'\ufffe' # 0xC4 -> UNDEFINED 380 | u'\ufffe' # 0xC5 -> UNDEFINED 381 | u'\ufffe' # 0xC6 -> UNDEFINED 382 | u'\ufffe' # 0xC7 -> UNDEFINED 383 | u'\ufffe' # 0xC8 -> UNDEFINED 384 | u'\ufffe' # 0xC9 -> UNDEFINED 385 | u'\ufffe' # 0xCA -> UNDEFINED 386 | u'\ufffe' # 0xCB -> UNDEFINED 387 | u'\ufffe' # 0xCC -> UNDEFINED 388 | u'\ufffe' # 0xCD -> UNDEFINED 389 | u'\ufffe' # 0xCE -> UNDEFINED 390 | u'\ufffe' # 0xCF -> UNDEFINED 391 | u'\ufffe' # 0xD0 -> UNDEFINED 392 | u'\ufffe' # 0xD1 -> UNDEFINED 393 | u'\ufffe' # 0xD2 -> UNDEFINED 394 | u'\ufffe' # 0xD3 -> UNDEFINED 395 | u'\ufffe' # 0xD4 -> UNDEFINED 396 | u'\ufffe' # 0xD5 -> UNDEFINED 397 | u'\ufffe' # 0xD6 -> UNDEFINED 398 | u'\ufffe' # 0xD7 -> UNDEFINED 399 | u'\ufffe' # 0xD8 -> UNDEFINED 400 | u'\ufffe' # 0xD9 -> UNDEFINED 401 | u'\ufffe' # 0xDA -> UNDEFINED 402 | u'\ufffe' # 0xDB -> UNDEFINED 403 | u'\ufffe' # 0xDC -> UNDEFINED 404 | u'\ufffe' # 0xDD -> UNDEFINED 405 | u'\ufffe' # 0xDE -> UNDEFINED 406 | u'\ufffe' # 0xDF -> UNDEFINED 407 | u'\ufffe' # 0xE0 -> UNDEFINED 408 | u'\ufffe' # 0xE1 -> UNDEFINED 409 | u'\ufffe' # 0xE2 -> UNDEFINED 410 | u'\ufffe' # 0xE3 -> UNDEFINED 411 | u'\ufffe' # 0xE4 -> UNDEFINED 412 | u'\ufffe' # 0xE5 -> UNDEFINED 413 | u'\ufffe' # 0xE6 -> UNDEFINED 414 | u'\ufffe' # 0xE7 -> UNDEFINED 415 | u'\ufffe' # 0xE8 -> UNDEFINED 416 | u'\ufffe' # 0xE9 -> UNDEFINED 417 | u'\ufffe' # 0xEA -> UNDEFINED 418 | u'\ufffe' # 0xEB -> UNDEFINED 419 | u'\ufffe' # 0xEC -> UNDEFINED 420 | u'\ufffe' # 0xED -> UNDEFINED 421 | u'\ufffe' # 0xEE -> UNDEFINED 422 | u'\ufffe' # 0xEF -> UNDEFINED 423 | u'\ufffe' # 0xF0 -> UNDEFINED 424 | u'\ufffe' # 0xF1 -> UNDEFINED 425 | u'\ufffe' # 0xF2 -> UNDEFINED 426 | u'\ufffe' # 0xF3 -> UNDEFINED 427 | u'\ufffe' # 0xF4 -> UNDEFINED 428 | u'\ufffe' # 0xF5 -> UNDEFINED 429 | u'\ufffe' # 0xF6 -> UNDEFINED 430 | u'\ufffe' # 0xF7 -> UNDEFINED 431 | u'\ufffe' # 0xF8 -> UNDEFINED 432 | u'\ufffe' # 0xF9 -> UNDEFINED 433 | u'\ufffe' # 0xFA -> UNDEFINED 434 | u'\ufffe' # 0xFB -> UNDEFINED 435 | u'\ufffe' # 0xFC -> UNDEFINED 436 | u'\ufffe' # 0xFD -> UNDEFINED 437 | u'\ufffe' # 0xFE -> UNDEFINED 438 | u'\ufffe' # 0xFF -> UNDEFINED 439 | ) 440 | 441 | encoding_table=codecs.charmap_build(decoding_table) 442 | 443 | 444 | if __name__ == "__main__": 445 | """ 446 | Run this as a script for poor-man's unit tests 447 | 448 | """ 449 | isoLatin15_alpha=u" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJLKMNOPQRSTUVWXYZ[\\]^-`abcdefghijklmnopqrstuvwxyz{|}~¡¢£€¥Š§š©ª«¬®¯°±²³Žµ¶·ž¹º»ŒœŸ¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ" 450 | 451 | gsm_alpha=u"\u00A0@£$¥èéùìòçØøÅåΔ_ΦΓΛΩΠΨΣΘΞ^{}\\[~]|\u00A0\u00A0€ÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà\u00A0" 452 | 453 | gsm_alpha_encoded='1b000102030405060708090b0c0e0f101112131415161718191a1b141b281b291b2f1b3c1b3d1b3e1b401b1b1b651c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f1b' 454 | 455 | gsm_alpha_gsm=gsm_alpha_encoded.decode('hex') 456 | 457 | 458 | # some simple tests 459 | print "Assert GSM alphabet, encoded in GSM is correct (unicode->gsm_str)..." 460 | encoded=_encode(gsm_alpha)[0].encode('hex') 461 | print encoded 462 | assert(encoded==gsm_alpha_encoded) 463 | print "Good" 464 | print 465 | print "Assert GSM encoded string converts to correct Unicode (gsm_str->unicode)..." 466 | assert(_decode(gsm_alpha_gsm)[0]==gsm_alpha) 467 | print "Good" 468 | print 469 | 470 | # test Codec objects 471 | print "Try the codec objects unicode_test_str->encode->decode==unicode_test_str..." 472 | c=Codec() 473 | gsm_str,out=c.encode(gsm_alpha) 474 | assert(c.decode(gsm_str)[0]==gsm_alpha) 475 | print "Good" 476 | print 477 | print "Try the incremental codecs, same test, but loop it..." 478 | 479 | def _inc_encode(ie): 480 | encoded=list() 481 | hop=17 # make it something odd 482 | final=False 483 | for i in range(0,len(gsm_alpha),hop): 484 | end=i+hop 485 | if end>=len(gsm_alpha): final=True 486 | encoded.append(ie.encode(gsm_alpha[i:end],final)) 487 | return ''.join(encoded) 488 | 489 | enc=IncrementalEncoder() 490 | assert(_inc_encode(enc)==gsm_alpha_gsm) 491 | print "Good" 492 | print 493 | print "Now do that again with the same encoder to make sure state is reset..." 494 | enc.reset() 495 | assert(_inc_encode(enc)==gsm_alpha_gsm) 496 | print "Good" 497 | print 498 | print "Now decode the encoded string back to unicode..." 499 | 500 | def _inc_decode(idec): 501 | decoded=list() 502 | # define so we KNOW we hit a mark as last char 503 | hop=gsm_alpha_gsm.index('\x1b')+1 504 | final=False 505 | for i in range(0,len(gsm_alpha_gsm),hop): 506 | end=i+hop 507 | if end>=len(gsm_alpha_gsm): final=True 508 | decoded.append(idec.decode(gsm_alpha_gsm[i:end],final)) 509 | return ''.join(decoded) 510 | 511 | dec=IncrementalDecoder() 512 | assert(_inc_decode(dec)==gsm_alpha) 513 | print "Good" 514 | print 515 | print "Do it again with some decoder to make sure state is cleared..." 516 | dec.reset() 517 | assert(_inc_decode(dec)==gsm_alpha) 518 | print "Good" 519 | print 520 | 521 | -------------------------------------------------------------------------------- /pygsm/gsmcodecs/mappings/GSM0338.TXT: -------------------------------------------------------------------------------- 1 | # 2 | # Name: GSM 03.38 to Unicode 3 | # Unicode version: 3.0 4 | # Table version: 1.1 5 | # Table format: Format A 6 | # Date: 2000 May 30 7 | # Authors: Ken Whistler 8 | # Kent Karlsson 9 | # Markus Kuhn 10 | # 11 | # Copyright (c) 2000 Unicode, Inc. All Rights reserved. 12 | # 13 | # This file is provided as-is by Unicode, Inc. (The Unicode Consortium). 14 | # No claims are made as to fitness for any particular purpose. No 15 | # warranties of any kind are expressed or implied. The recipient 16 | # agrees to determine applicability of information provided. If this 17 | # file has been provided on optical media by Unicode, Inc., the sole 18 | # remedy for any claim will be exchange of defective media within 90 19 | # days of receipt. 20 | # 21 | # Unicode, Inc. hereby grants the right to freely use the information 22 | # supplied in this file in the creation of products supporting the 23 | # Unicode Standard, and to make copies of this file in any form for 24 | # internal or external distribution as long as this notice remains 25 | # attached. 26 | # 27 | # General notes: 28 | # 29 | # This table contains the data the Unicode Consortium has on how 30 | # ETSI GSM 03.38 7-bit default alphabet characters map into Unicode. 31 | # This mapping is based on ETSI TS 100 900 V7.2.0 (1999-07), with 32 | # a correction of 0x09 to *small* c-cedilla, instead of *capital* 33 | # C-cedilla. 34 | # 35 | # Format: Three tab-separated columns 36 | # Column #1 is the ETSI GSM 03.38 7-bit default alphabet 37 | # code (in hex as 0xXX, or 0xXXXX for double-byte 38 | # sequences) 39 | # Column #2 is the Unicode scalar value (in hex as 0xXXXX) 40 | # Column #3 the Unicode name (follows a comment sign, '#') 41 | # 42 | # The entries are in ETSI GSM 03.38 7-bit default alphabet code order. 43 | # 44 | # Note that ETSI GSM 03.38 also allows for the use of UCS-2 (UTF-16 45 | # restricted to the BMP) in GSM/SMS messages. 46 | # 47 | # Note also that there are commented Greek mappings for some 48 | # capital Latin characters. This follows from the clear intent 49 | # of the ETSI GSM 03.38 to have glyph coverage for the uppercase 50 | # Greek alphabet by reusing Latin letters that have the same 51 | # form as an uppercase Greek letter. Conversion implementations 52 | # should be aware of this fact. 53 | # 54 | # The ETSI GSM 03.38 specification shows an uppercase C-cedilla 55 | # glyph at 0x09. This may be the result of limited display 56 | # capabilities for handling characters with descenders. However, the 57 | # language coverage intent is clearly for the lowercase c-cedilla, as shown 58 | # in the mapping below. The mapping for uppercase C-cedilla is shown 59 | # in a commented line in the mapping table. 60 | # 61 | # The ESC character 0x1B is 62 | # mapped to the no-break space character, unless it is part of a 63 | # valid ESC sequence, to facilitate round-trip compatibility in 64 | # the presence of unknown ESC sequences. 65 | # 66 | # 0x00 is NULL (when followed only by 0x00 up to the 67 | # end of (fixed byte length) message, possibly also up to 68 | # FORM FEED. But 0x00 is also the code for COMMERCIAL AT 69 | # when some other character (CARRIAGE RETURN if nothing else) 70 | # comes after the 0x00. 71 | # 72 | # Version history 73 | # 1.0 version: first creation 74 | # 1.1 version: fixed problem with the wrong line being a comment, 75 | # added text regarding 0x00's interpretation, 76 | # added second mapping for C-cedilla, 77 | # added mapping of 0x1B escape to NBSP for display. 78 | # 79 | # Updated versions of this file may be found in: 80 | # 81 | # 82 | # Any comments or problems, contact 83 | # Please note that is an archival address; 84 | # notices will be checked, but do not expect an immediate response. 85 | # 86 | 0x00 0x0040 # COMMERCIAL AT 87 | #0x00 0x0000 # NULL (see note above) 88 | 0x01 0x00A3 # POUND SIGN 89 | 0x02 0x0024 # DOLLAR SIGN 90 | 0x03 0x00A5 # YEN SIGN 91 | 0x04 0x00E8 # LATIN SMALL LETTER E WITH GRAVE 92 | 0x05 0x00E9 # LATIN SMALL LETTER E WITH ACUTE 93 | 0x06 0x00F9 # LATIN SMALL LETTER U WITH GRAVE 94 | 0x07 0x00EC # LATIN SMALL LETTER I WITH GRAVE 95 | 0x08 0x00F2 # LATIN SMALL LETTER O WITH GRAVE 96 | 0x09 0x00E7 # LATIN SMALL LETTER C WITH CEDILLA 97 | #0x09 0x00C7 # LATIN CAPITAL LETTER C WITH CEDILLA (see note above) 98 | 0x0A 0x000A # LINE FEED 99 | 0x0B 0x00D8 # LATIN CAPITAL LETTER O WITH STROKE 100 | 0x0C 0x00F8 # LATIN SMALL LETTER O WITH STROKE 101 | 0x0D 0x000D # CARRIAGE RETURN 102 | 0x0E 0x00C5 # LATIN CAPITAL LETTER A WITH RING ABOVE 103 | 0x0F 0x00E5 # LATIN SMALL LETTER A WITH RING ABOVE 104 | 0x10 0x0394 # GREEK CAPITAL LETTER DELTA 105 | 0x11 0x005F # LOW LINE 106 | 0x12 0x03A6 # GREEK CAPITAL LETTER PHI 107 | 0x13 0x0393 # GREEK CAPITAL LETTER GAMMA 108 | 0x14 0x039B # GREEK CAPITAL LETTER LAMDA 109 | 0x15 0x03A9 # GREEK CAPITAL LETTER OMEGA 110 | 0x16 0x03A0 # GREEK CAPITAL LETTER PI 111 | 0x17 0x03A8 # GREEK CAPITAL LETTER PSI 112 | 0x18 0x03A3 # GREEK CAPITAL LETTER SIGMA 113 | 0x19 0x0398 # GREEK CAPITAL LETTER THETA 114 | 0x1A 0x039E # GREEK CAPITAL LETTER XI 115 | 0x1B 0x00A0 # ESCAPE TO EXTENSION TABLE (or displayed as NBSP, see note above) 116 | 0x1B0A 0x000C # FORM FEED 117 | 0x1B14 0x005E # CIRCUMFLEX ACCENT 118 | 0x1B28 0x007B # LEFT CURLY BRACKET 119 | 0x1B29 0x007D # RIGHT CURLY BRACKET 120 | 0x1B2F 0x005C # REVERSE SOLIDUS 121 | 0x1B3C 0x005B # LEFT SQUARE BRACKET 122 | 0x1B3D 0x007E # TILDE 123 | 0x1B3E 0x005D # RIGHT SQUARE BRACKET 124 | 0x1B40 0x007C # VERTICAL LINE 125 | 0x1B65 0x20AC # EURO SIGN 126 | 0x1C 0x00C6 # LATIN CAPITAL LETTER AE 127 | 0x1D 0x00E6 # LATIN SMALL LETTER AE 128 | 0x1E 0x00DF # LATIN SMALL LETTER SHARP S (German) 129 | 0x1F 0x00C9 # LATIN CAPITAL LETTER E WITH ACUTE 130 | 0x20 0x0020 # SPACE 131 | 0x21 0x0021 # EXCLAMATION MARK 132 | 0x22 0x0022 # QUOTATION MARK 133 | 0x23 0x0023 # NUMBER SIGN 134 | 0x24 0x00A4 # CURRENCY SIGN 135 | 0x25 0x0025 # PERCENT SIGN 136 | 0x26 0x0026 # AMPERSAND 137 | 0x27 0x0027 # APOSTROPHE 138 | 0x28 0x0028 # LEFT PARENTHESIS 139 | 0x29 0x0029 # RIGHT PARENTHESIS 140 | 0x2A 0x002A # ASTERISK 141 | 0x2B 0x002B # PLUS SIGN 142 | 0x2C 0x002C # COMMA 143 | 0x2D 0x002D # HYPHEN-MINUS 144 | 0x2E 0x002E # FULL STOP 145 | 0x2F 0x002F # SOLIDUS 146 | 0x30 0x0030 # DIGIT ZERO 147 | 0x31 0x0031 # DIGIT ONE 148 | 0x32 0x0032 # DIGIT TWO 149 | 0x33 0x0033 # DIGIT THREE 150 | 0x34 0x0034 # DIGIT FOUR 151 | 0x35 0x0035 # DIGIT FIVE 152 | 0x36 0x0036 # DIGIT SIX 153 | 0x37 0x0037 # DIGIT SEVEN 154 | 0x38 0x0038 # DIGIT EIGHT 155 | 0x39 0x0039 # DIGIT NINE 156 | 0x3A 0x003A # COLON 157 | 0x3B 0x003B # SEMICOLON 158 | 0x3C 0x003C # LESS-THAN SIGN 159 | 0x3D 0x003D # EQUALS SIGN 160 | 0x3E 0x003E # GREATER-THAN SIGN 161 | 0x3F 0x003F # QUESTION MARK 162 | 0x40 0x00A1 # INVERTED EXCLAMATION MARK 163 | 0x41 0x0041 # LATIN CAPITAL LETTER A 164 | #0x41 0x0391 # GREEK CAPITAL LETTER ALPHA 165 | 0x42 0x0042 # LATIN CAPITAL LETTER B 166 | #0x42 0x0392 # GREEK CAPITAL LETTER BETA 167 | 0x43 0x0043 # LATIN CAPITAL LETTER C 168 | 0x44 0x0044 # LATIN CAPITAL LETTER D 169 | 0x45 0x0045 # LATIN CAPITAL LETTER E 170 | #0x45 0x0395 # GREEK CAPITAL LETTER EPSILON 171 | 0x46 0x0046 # LATIN CAPITAL LETTER F 172 | 0x47 0x0047 # LATIN CAPITAL LETTER G 173 | 0x48 0x0048 # LATIN CAPITAL LETTER H 174 | #0x48 0x0397 # GREEK CAPITAL LETTER ETA 175 | 0x49 0x0049 # LATIN CAPITAL LETTER I 176 | #0x49 0x0399 # GREEK CAPITAL LETTER IOTA 177 | 0x4A 0x004A # LATIN CAPITAL LETTER J 178 | 0x4B 0x004B # LATIN CAPITAL LETTER K 179 | #0x4B 0x039A # GREEK CAPITAL LETTER KAPPA 180 | 0x4C 0x004C # LATIN CAPITAL LETTER L 181 | 0x4D 0x004D # LATIN CAPITAL LETTER M 182 | #0x4D 0x039C # GREEK CAPITAL LETTER MU 183 | 0x4E 0x004E # LATIN CAPITAL LETTER N 184 | #0x4E 0x039D # GREEK CAPITAL LETTER NU 185 | 0x4F 0x004F # LATIN CAPITAL LETTER O 186 | #0x4F 0x039F # GREEK CAPITAL LETTER OMICRON 187 | 0x50 0x0050 # LATIN CAPITAL LETTER P 188 | #0x50 0x03A1 # GREEK CAPITAL LETTER RHO 189 | 0x51 0x0051 # LATIN CAPITAL LETTER Q 190 | 0x52 0x0052 # LATIN CAPITAL LETTER R 191 | 0x53 0x0053 # LATIN CAPITAL LETTER S 192 | 0x54 0x0054 # LATIN CAPITAL LETTER T 193 | #0x54 0x03A4 # GREEK CAPITAL LETTER TAU 194 | 0x55 0x0055 # LATIN CAPITAL LETTER U 195 | #0x55 0x03A5 # GREEK CAPITAL LETTER UPSILON 196 | 0x56 0x0056 # LATIN CAPITAL LETTER V 197 | 0x57 0x0057 # LATIN CAPITAL LETTER W 198 | 0x58 0x0058 # LATIN CAPITAL LETTER X 199 | #0x58 0x03A7 # GREEK CAPITAL LETTER CHI 200 | 0x59 0x0059 # LATIN CAPITAL LETTER Y 201 | 0x5A 0x005A # LATIN CAPITAL LETTER Z 202 | #0x5A 0x0396 # GREEK CAPITAL LETTER ZETA 203 | 0x5B 0x00C4 # LATIN CAPITAL LETTER A WITH DIAERESIS 204 | 0x5C 0x00D6 # LATIN CAPITAL LETTER O WITH DIAERESIS 205 | 0x5D 0x00D1 # LATIN CAPITAL LETTER N WITH TILDE 206 | 0x5E 0x00DC # LATIN CAPITAL LETTER U WITH DIAERESIS 207 | 0x5F 0x00A7 # SECTION SIGN 208 | 0x60 0x00BF # INVERTED QUESTION MARK 209 | 0x61 0x0061 # LATIN SMALL LETTER A 210 | 0x62 0x0062 # LATIN SMALL LETTER B 211 | 0x63 0x0063 # LATIN SMALL LETTER C 212 | 0x64 0x0064 # LATIN SMALL LETTER D 213 | 0x65 0x0065 # LATIN SMALL LETTER E 214 | 0x66 0x0066 # LATIN SMALL LETTER F 215 | 0x67 0x0067 # LATIN SMALL LETTER G 216 | 0x68 0x0068 # LATIN SMALL LETTER H 217 | 0x69 0x0069 # LATIN SMALL LETTER I 218 | 0x6A 0x006A # LATIN SMALL LETTER J 219 | 0x6B 0x006B # LATIN SMALL LETTER K 220 | 0x6C 0x006C # LATIN SMALL LETTER L 221 | 0x6D 0x006D # LATIN SMALL LETTER M 222 | 0x6E 0x006E # LATIN SMALL LETTER N 223 | 0x6F 0x006F # LATIN SMALL LETTER O 224 | 0x70 0x0070 # LATIN SMALL LETTER P 225 | 0x71 0x0071 # LATIN SMALL LETTER Q 226 | 0x72 0x0072 # LATIN SMALL LETTER R 227 | 0x73 0x0073 # LATIN SMALL LETTER S 228 | 0x74 0x0074 # LATIN SMALL LETTER T 229 | 0x75 0x0075 # LATIN SMALL LETTER U 230 | 0x76 0x0076 # LATIN SMALL LETTER V 231 | 0x77 0x0077 # LATIN SMALL LETTER W 232 | 0x78 0x0078 # LATIN SMALL LETTER X 233 | 0x79 0x0079 # LATIN SMALL LETTER Y 234 | 0x7A 0x007A # LATIN SMALL LETTER Z 235 | 0x7B 0x00E4 # LATIN SMALL LETTER A WITH DIAERESIS 236 | 0x7C 0x00F6 # LATIN SMALL LETTER O WITH DIAERESIS 237 | 0x7D 0x00F1 # LATIN SMALL LETTER N WITH TILDE 238 | 0x7E 0x00FC # LATIN SMALL LETTER U WITH DIAERESIS 239 | 0x7F 0x00E0 # LATIN SMALL LETTER A WITH GRAVE 240 | -------------------------------------------------------------------------------- /pygsm/gsmmodem.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 encoding=utf-8 3 | 4 | 5 | from __future__ import with_statement 6 | 7 | # debian/ubuntu: apt-get install python-tz 8 | 9 | import re 10 | import time 11 | import errors 12 | import threading 13 | import gsmcodecs 14 | from devicewrapper import DeviceWrapper 15 | from pdusmshandler import PduSmsHandler 16 | from textsmshandler import TextSmsHandler 17 | 18 | class GsmModem(object): 19 | """pyGSM is a Python module which uses pySerial to provide a nifty 20 | interface to send and receive SMS via a GSM Modem. It was ported 21 | from RubyGSM, and provides (almost) all of the same features. It's 22 | easy to get started: 23 | 24 | # create a GsmModem object: 25 | >>> import pygsm 26 | >>> modem = pygsm.GsmModem(port="/dev/ttyUSB0") 27 | 28 | # harass Evan over SMS: 29 | # (try to do this before 11AM) 30 | >>> modem.send_sms("+13364130840", "Hey, wake up!") 31 | 32 | # check for incoming SMS: 33 | >>> print modem.next_message() 34 | 35 | 36 | 37 | There are various ways of polling for incoming messages -- a choice 38 | which has been deliberately left to the application author (unlike 39 | RubyGSM). Execute `python -m pygsm.gsmmodem` to run this example: 40 | 41 | # connect to the modem 42 | modem = pygsm.GsmModem(port=sys.argv[1]) 43 | 44 | # check for new messages every two 45 | # seconds for the rest of forever 46 | while True: 47 | msg = modem.next_message() 48 | 49 | # we got a message! respond with 50 | # something useless, as an example 51 | if msg is not None: 52 | msg.respond("Thanks for those %d characters!" % 53 | len(msg.text)) 54 | 55 | # no messages? wait a couple 56 | # of seconds and try again 57 | else: time.sleep(2) 58 | 59 | 60 | pyGSM is distributed via GitHub: 61 | http://github.com/adammck/pygsm 62 | 63 | Bugs reports (especially for 64 | unsupported devices) are welcome: 65 | http://github.com/adammck/pygsm/issues""" 66 | 67 | 68 | # override these after init, and 69 | # before boot. they're not sanity 70 | # checked, so go crazy. 71 | cmd_delay = 0.1 72 | retry_delay = 2 73 | max_retries = 10 74 | modem_lock = threading.RLock() 75 | 76 | 77 | def __init__(self, *args, **kwargs): 78 | """Creates, connects to, and boots a GSM Modem. All of the arguments 79 | are optional (although "port=" should almost always be provided), 80 | and passed along to serial.Serial.__init__ verbatim. For all of 81 | the possible configration options, see: 82 | 83 | http://pyserial.wiki.sourceforge.net/pySerial#tocpySerial10 84 | 85 | Alternatively, a single "device" kwarg can be passed, which overrides 86 | the default proxy-args-to-pySerial behavior. This is useful when testing, 87 | or wrapping the serial connection with some custom logic.""" 88 | 89 | if "logger" in kwargs: 90 | self.logger = kwargs.pop("logger") 91 | 92 | mode = "PDU" 93 | if "mode" in kwargs: 94 | mode = kwargs.pop("mode") 95 | 96 | # if a ready-made device was provided, store it -- self.connect 97 | # will see that we're already connected, and do nothing. we'll 98 | # just assume it quacks like a serial port 99 | if "device" in kwargs: 100 | self.device = kwargs.pop("device") 101 | 102 | # if a device is given, the other args are never 103 | # used, so were probably included by mistake. 104 | if len(args) or len(kwargs): 105 | raise(TypeError("__init__() does not accept other arguments when a 'device' is given")) 106 | 107 | # for regular serial connections, store the connection args, since 108 | # we might need to recreate the serial connection again later 109 | else: 110 | self.device_args = args 111 | self.device_kwargs = kwargs 112 | 113 | # to cache parts of multi-part messages 114 | # until the last part is delivered 115 | self.multipart = {} 116 | 117 | # to store unhandled incoming messages 118 | self.incoming_queue = [] 119 | 120 | if mode.lower() == "text": 121 | self.smshandler = TextSmsHandler(self) 122 | else: 123 | self.smshandler = PduSmsHandler(self) 124 | # boot the device on init, to fail as 125 | # early as possible if it can't be opened 126 | self.boot() 127 | 128 | 129 | LOG_LEVELS = { 130 | "traffic": 4, 131 | "read": 4, 132 | "write": 4, 133 | "debug": 3, 134 | "warn": 2, 135 | "error": 1 } 136 | 137 | 138 | def _log(self, str_, type_="debug"): 139 | """Proxies a log message to this Modem's logger, if one has been set. 140 | This is useful for applications embedding pyGSM that wish to show 141 | or log what's going on inside. 142 | 143 | The *logger* should be a function with three arguments: 144 | modem: a reference to this GsmModem instance 145 | message: the log message (a unicode string) 146 | type: a string contaning one of the keys 147 | of GsmModem.LOG_LEVELS, indicating 148 | the importance of this message. 149 | 150 | GsmModem.__init__ accepts an optional "logger" kwarg, and a minimal 151 | (dump to STDOUT) logger is available at GsmModem.logger: 152 | 153 | >>> GsmModem("/dev/ttyUSB0", logger=GsmModem.logger)""" 154 | 155 | if hasattr(self, "logger"): 156 | self.logger(self, str_, type_) 157 | 158 | 159 | @staticmethod 160 | def logger(_modem, message_, type_): 161 | print "%8s %s" % (type_, message_) 162 | 163 | 164 | def connect(self, reconnect=False): 165 | """Creates the connection to the modem via pySerial, optionally 166 | killing and re-creating any existing connection.""" 167 | 168 | self._log("Connecting") 169 | 170 | # if no connection exists, create it 171 | # the reconnect flag is irrelevant 172 | if not hasattr(self, "device") or (self.device is None): 173 | with self.modem_lock: 174 | self.device = DeviceWrapper( 175 | self.logger, *self.device_args, 176 | **self.device_kwargs) 177 | 178 | # the port already exists, but if we're 179 | # reconnecting, then kill it and recurse 180 | # to recreate it. this is useful when the 181 | # connection has died, but nobody noticed 182 | elif reconnect: 183 | 184 | self.disconnect() 185 | self.connect(False) 186 | 187 | return self.device 188 | 189 | 190 | def disconnect(self): 191 | """Disconnects from the modem.""" 192 | 193 | self._log("Disconnecting") 194 | 195 | # attempt to close and destroy the device 196 | if hasattr(self, "device") and (self.device is not None): 197 | with self.modem_lock: 198 | if self.device.isOpen(): 199 | self.device.close() 200 | self.device = None 201 | return True 202 | 203 | # for some reason, the device 204 | # couldn't be closed. it probably 205 | # just isn't open yet 206 | return False 207 | 208 | 209 | def set_modem_config(self): 210 | """initialize the modem configuration with settings needed to process 211 | commands and send/receive SMS. 212 | """ 213 | 214 | # set some sensible defaults, to make 215 | # the various modems more consistant 216 | self.command("ATE0", raise_errors=False) # echo off 217 | self.command("AT+CMEE=1", raise_errors=False) # useful error messages 218 | self.command("AT+WIND=0", raise_errors=False) # disable notifications 219 | self.command("AT+CSMS=1", raise_errors=False) # set SMS mode to phase 2+ 220 | self.command(self.smshandler.get_mode_cmd() ) # make sure in PDU mode 221 | 222 | # enable new message notification 223 | self.command( 224 | "AT+CNMI=2,2,0,0,0", 225 | raise_errors=False) 226 | 227 | 228 | def boot(self, reboot=False): 229 | """Initializes the modem. Must be called after init and connect, 230 | but before doing anything that expects the modem to be ready.""" 231 | 232 | self._log("Booting") 233 | 234 | if reboot: 235 | # If reboot==True, force a reconnection and full modem reset. SLOW 236 | self.connect(reconnect=True) 237 | self.command("AT+CFUN=1") 238 | else: 239 | # else just verify connection 240 | self.connect() 241 | 242 | # In both cases, reset the modem's config 243 | self.set_modem_config() 244 | 245 | # And check for any waiting messages PRIOR to setting 246 | # the CNMI call--this is not supported by all modems-- 247 | # in which case we catch the exception and plow onward 248 | try: 249 | self._fetch_stored_messages() 250 | except errors.GsmError: 251 | pass 252 | 253 | 254 | def reboot(self): 255 | """Forces a reconnect to the serial port and then a full modem reset to factory 256 | and reconnect to GSM network. SLOW. 257 | """ 258 | self.boot(reboot=True) 259 | 260 | 261 | def _write(self, str): 262 | """Write a string to the modem.""" 263 | 264 | self._log(repr(str), "write") 265 | 266 | try: 267 | self.device.write(str) 268 | 269 | # if the device couldn't be written to, 270 | # wrap the error in something that can 271 | # sensibly be caught at a higher level 272 | except OSError, err: 273 | raise(errors.GsmWriteError) 274 | 275 | def _parse_incoming_sms(self, lines): 276 | """Parse a list of lines (the output of GsmModem._wait), to extract any 277 | incoming SMS and append them to GsmModem.incoming_queue. Returns the 278 | same lines with the incoming SMS removed. Other unsolicited data may 279 | remain, which must be cropped separately.""" 280 | 281 | output_lines = [] 282 | n = 0 283 | 284 | # iterate the lines like it's 1984 285 | # (because we're patching the array, 286 | # which is hard work for iterators) 287 | while n < len(lines): 288 | 289 | # not a CMT string? add it back into the 290 | # output (since we're not interested in it) 291 | # and move on to the next 292 | if lines[n][0:5] != "+CMT:": 293 | output_lines.append(lines[n]) 294 | n += 1 295 | continue 296 | 297 | msg_line = lines[n+1].strip() 298 | 299 | # notify the network that we accepted 300 | # the incoming message (for read receipt) 301 | # BEFORE pushing it to the incoming queue 302 | # (to avoid really ugly race condition if 303 | # the message is grabbed from the queue 304 | # and responded to quickly, before we get 305 | # a chance to issue at+cnma) 306 | try: 307 | self.command("AT+CNMA") 308 | 309 | # Some networks don't handle notification, in which case this 310 | # fails. Not a big deal, so ignore. 311 | except errors.GsmError: 312 | #self.log("Receipt acknowledgement (CNMA) was rejected") 313 | # TODO: also log this! 314 | pass 315 | 316 | msg = self.smshandler.parse_incoming_message(lines[n], msg_line) 317 | if msg is not None: 318 | self.incoming_queue.append(msg) 319 | 320 | # jump over the CMT line, and the 321 | # pdu line, and continue iterating 322 | n += 2 323 | 324 | # return the lines that we weren't 325 | # interested in (almost all of them!) 326 | return output_lines 327 | 328 | def command(self, cmd, read_term=None, read_timeout=None, write_term="\r", raise_errors=True): 329 | """Issue a single AT command to the modem, and return the sanitized 330 | response. Sanitization removes status notifications, command echo, 331 | and incoming messages, (hopefully) leaving only the actual response 332 | from the command. 333 | 334 | If Error 515 (init or command in progress) is returned, the command 335 | is automatically retried up to _GsmModem.max_retries_ times.""" 336 | 337 | # keep looping until the command 338 | # succeeds or we hit the limit 339 | retries = 0 340 | while retries < self.max_retries: 341 | try: 342 | 343 | # issue the command, and wait for the 344 | # response 345 | with self.modem_lock: 346 | self._write(cmd + write_term) 347 | lines = self.device.read_lines( 348 | read_term=read_term, 349 | read_timeout=read_timeout) 350 | 351 | # no exception was raised, so break 352 | # out of the enclosing WHILE loop 353 | break 354 | 355 | # Outer handler: if the command caused an error, 356 | # maybe wrap it and return None 357 | except errors.GsmError, err: 358 | # if GSM Error 515 (init or command in progress) was raised, 359 | # lock the thread for a short while, and retry. don't lock 360 | # the modem while we're waiting, because most commands WILL 361 | # work during the init period - just not _cmd_ 362 | if getattr(err, "code", None) == 515: 363 | time.sleep(self.retry_delay) 364 | retries += 1 365 | continue 366 | 367 | # if raise_errors is disabled, it doesn't matter 368 | # *what* went wrong - we'll just ignore it 369 | if not raise_errors: 370 | return None 371 | 372 | # otherwise, allow errors to propagate upwards, 373 | # and hope someone is waiting to catch them 374 | else: 375 | raise(err) 376 | 377 | # if the first line of the response echoes the cmd 378 | # (it shouldn't, if ATE0 worked), silently drop it 379 | if lines[0] == cmd: 380 | lines.pop(0) 381 | 382 | # remove all blank lines and unsolicited 383 | # status messages. i can't seem to figure 384 | # out how to reliably disable them, and 385 | # AT+WIND=0 doesn't work on this modem 386 | lines = [ 387 | line 388 | for line in lines 389 | if line != "" or\ 390 | line[0:6] == "+WIND:" or\ 391 | line[0:6] == "+CREG:" or\ 392 | line[0:7] == "+CGRED:"] 393 | 394 | # parse out any incoming sms that were bundled 395 | # with this data (to be fetched later by an app) 396 | lines = self._parse_incoming_sms(lines) 397 | 398 | # rest up for a bit (modems are 399 | # slow, and get confused easily) 400 | time.sleep(self.cmd_delay) 401 | 402 | return lines 403 | 404 | 405 | def query(self, cmd, prefix=None): 406 | """Issues a single AT command to the modem, and returns the relevant 407 | part of the response. This only works for commands that return a 408 | single line followed by "OK", but conveniently, this covers almost 409 | all AT commands that I've ever needed to use. 410 | 411 | For all other commands, returns None.""" 412 | 413 | # issue the command, which might return incoming 414 | # messages, but we'll leave them in the queue 415 | out = self.command(cmd) 416 | 417 | # the only valid response to a "query" is a 418 | # single line followed by "OK". if all looks 419 | # well, return just the single line 420 | if(len(out) == 2) and (out[-1] == "OK"): 421 | if prefix is None: 422 | return out[0].strip() 423 | 424 | # if a prefix was provided, check that the 425 | # response starts with it, and return the 426 | # cropped remainder 427 | else: 428 | if out[0][:len(prefix)] == prefix: 429 | return out[0][len(prefix):].strip() 430 | 431 | # something went wrong, so return the very 432 | # ambiguous None. it's better than blowing up 433 | return None 434 | 435 | 436 | def send_sms(self, recipient, text, max_messages = 255): 437 | """ 438 | Sends an SMS to _recipient_ containing _text_. 439 | 440 | Method will automatically split long 'text' into 441 | multiple SMSs up to max_messages. 442 | 443 | To enforce only a single SMS, set max_messages=1 444 | 445 | If max_messages > 255 it is forced to 255 446 | If max_messages < 1 it is forced to 1 447 | 448 | Raises 'ValueError' if text will not fit in max_messages 449 | 450 | NOTE: Only PDU mode respects max_messages! It has no effect in TEXT mode 451 | 452 | """ 453 | mm = 255 454 | try: 455 | mm = int(max_messages) 456 | except: 457 | # dunno what type mm was, so just leave at deafult 255 458 | pass 459 | 460 | if mm > 255: 461 | mm = 255 462 | elif mm < 1: 463 | mm = 1 464 | 465 | with self.modem_lock: 466 | self.smshandler.send_sms(recipient, text, mm) 467 | return True 468 | 469 | def break_out_of_prompt(self): 470 | self._write(chr(27)) 471 | 472 | def hardware(self): 473 | """Returns a dict of containing information about the physical 474 | modem. The contents of each value are entirely manufacturer 475 | dependant, and vary wildly between devices.""" 476 | 477 | return { 478 | "manufacturer": self.query("AT+CGMI"), 479 | "model": self.query("AT+CGMM"), 480 | "revision": self.query("AT+CGMR"), 481 | "serial": self.query("AT+CGSN") } 482 | 483 | 484 | def signal_strength(self): 485 | """Returns an integer between 1 and 99, representing the current 486 | signal strength of the GSM network, False if we don't know, or 487 | None if the modem can't report it.""" 488 | 489 | data = self.query("AT+CSQ") 490 | md = re.match(r"^\+CSQ: (\d+),", data) 491 | 492 | # 99 represents "not known or not detectable". we'll 493 | # return False for that (so we can test it for boolean 494 | # equality), or an integer of the signal strength. 495 | if md is not None: 496 | csq = int(md.group(1)) 497 | return csq if csq < 99 else False 498 | 499 | # the response from AT+CSQ couldn't be parsed. return 500 | # None, so we can test it in the same way as False, but 501 | # check the type without raising an exception 502 | return None 503 | 504 | 505 | def wait_for_network(self): 506 | """Blocks until the signal strength indicates that the 507 | device is active on the GSM network. It's a good idea 508 | to call this before trying to send or receive anything.""" 509 | 510 | while True: 511 | csq = self.signal_strength() 512 | if csq: return csq 513 | time.sleep(1) 514 | 515 | 516 | def ping(self): 517 | """Sends the "AT" command to the device, and returns true 518 | if it is acknowledged. Since incoming notifications and 519 | messages are intercepted automatically, this is a good 520 | way to poll for new messages without using a worker 521 | thread like RubyGSM.""" 522 | 523 | try: 524 | self.command("AT") 525 | return True 526 | 527 | except errors.GsmError: 528 | return None 529 | 530 | 531 | def _strip_ok(self,lines): 532 | """Strip 'OK' from end of command response""" 533 | if lines is not None and len(lines)>0 and \ 534 | lines[-1]=='OK': 535 | lines=lines[:-1] # strip last entry 536 | return lines 537 | 538 | 539 | def _fetch_stored_messages(self): 540 | """ 541 | Fetch stored messages with CMGL and add to incoming queue 542 | Return number fetched 543 | 544 | """ 545 | lines = self.command('AT+CMGL=%s' % self.smshandler.CMGL_STATUS) 546 | lines = self._strip_ok(lines) 547 | messages = self.smshandler.parse_stored_messages(lines) 548 | for msg in messages: 549 | self.incoming_queue.append(msg) 550 | 551 | def next_message(self, ping=True, fetch=True): 552 | """Returns the next waiting IncomingMessage object, or None if the 553 | queue is empty. The optional _ping_ and _fetch_ parameters control 554 | whether the modem is pinged (to allow new messages to be delivered 555 | instantly, on those modems which support it) and queried for unread 556 | messages in storage, which can both be disabled in case you're 557 | already polling in a separate thread.""" 558 | 559 | # optionally ping the modem, to give it a 560 | # chance to deliver any waiting messages 561 | if ping: 562 | self.ping() 563 | 564 | # optionally check the storage for unread messages. 565 | # we must do this just as often as ping, because most 566 | # handsets don't support CNMI-style delivery 567 | if fetch: 568 | self._fetch_stored_messages() 569 | 570 | # abort if there are no messages waiting 571 | if not self.incoming_queue: 572 | return None 573 | 574 | # remove the message that has been waiting 575 | # longest from the queue, and return it 576 | return self.incoming_queue.pop(0) 577 | 578 | 579 | if __name__ == "__main__": 580 | 581 | import sys 582 | if len(sys.argv) >= 2: 583 | 584 | # the first argument is SERIAL PORT 585 | # (required, since we have no autodetect yet) 586 | port = sys.argv[1] 587 | 588 | # all subsequent options are parsed as key=value 589 | # pairs, to be passed on to GsmModem.__init__ as 590 | # kwargs, to configure the serial connection 591 | conf = dict([ 592 | arg.split("=", 1) 593 | for arg in sys.argv[2:] 594 | if arg.find("=") > -1 595 | ]) 596 | 597 | # dump the connection settings 598 | print "pyGSM Demo App" 599 | print " Port: %s" % (port) 600 | print " Config: %r" % (conf) 601 | print 602 | 603 | # connect to the modem (this might hang 604 | # if the connection settings are wrong) 605 | print "Connecting to GSM Modem..." 606 | modem = GsmModem(port=port, **conf) 607 | print "Waiting for incoming messages..." 608 | 609 | # check for new messages every two 610 | # seconds for the rest of forever 611 | while True: 612 | msg = modem.next_message() 613 | 614 | # we got a message! respond with 615 | # something useless, as an example 616 | if msg is not None: 617 | print "Got Message: %r" % msg 618 | msg.respond("Received: %d characters '%s'" % 619 | (len(msg.text),msg.text)) 620 | 621 | # no messages? wait a couple 622 | # of seconds and try again 623 | else: 624 | time.sleep(2) 625 | 626 | # the serial port must be provided 627 | # we're not auto-detecting, yet 628 | else: 629 | print "Usage: python -m pygsm.gsmmodem PORT [OPTIONS]" 630 | -------------------------------------------------------------------------------- /pygsm/gsmpdu.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 encoding=utf-8 3 | 4 | 5 | from __future__ import with_statement 6 | 7 | import re, datetime 8 | import math 9 | import pytz 10 | import codecs 11 | import gsmcodecs 12 | import threading 13 | 14 | MSG_LIMITS = { 15 | # 'encoding', (max_normal, max_csm) 16 | 'gsm': (160,152), 17 | 'ucs2': (70,67) 18 | } 19 | MAX_CSM_SEGMENTS = 255 20 | 21 | # used to track csm reference numbers per receiver 22 | __csm_refs = {} 23 | __ref_lock = threading.Lock() 24 | 25 | def get_outbound_pdus(text, recipient): 26 | """ 27 | Returns a list of PDUs to send the provided 28 | text to the given recipient. 29 | 30 | If everything fits in one message, the list 31 | will have just one PDU. 32 | 33 | Otherwise is will be a list of Concatenated SM PDUs 34 | 35 | If the message goes beyond the max length for a CSM 36 | (it's gotta be _REALLY BIG_), this will raise a 'ValueError' 37 | 38 | """ 39 | 40 | # first figure out the encoding 41 | # if 'gsm', encode it to account for 42 | # multi-byte char length 43 | encoding = 'ucs2' 44 | try: 45 | encoded_text = text.encode('gsm') 46 | encoding = 'gsm' 47 | except: 48 | encoded_text = text 49 | 50 | csm_max = MSG_LIMITS[encoding][1] 51 | if len(encoded_text)>(MAX_CSM_SEGMENTS*csm_max): 52 | raise ValueError('Message text too long') 53 | 54 | # see if we are under the single PDU limit 55 | if len(encoded_text)<=MSG_LIMITS[encoding][0]: 56 | return [OutboundGsmPdu(text, recipient)] 57 | 58 | # ok, we are a CSM, so lets figure out 59 | # the parts 60 | 61 | # get our ref 62 | with __ref_lock: 63 | if recipient not in __csm_refs: 64 | __csm_refs[recipient]=0 65 | csm_ref = __csm_refs[recipient] % 256 66 | __csm_refs[recipient]+=1 67 | 68 | # make the PDUs 69 | num = int(math.ceil(len(encoded_text)/float(MSG_LIMITS[encoding][0]))) 70 | pdus=[] 71 | for seq in range(num): 72 | i = seq*csm_max 73 | seg_txt = encoded_text[i:i+csm_max] 74 | if encoding=='gsm': 75 | # a little silly to encode, decode, then have PDU 76 | # re-encode but keeps PDU API clean 77 | seg_txt = seg_txt.decode('gsm') 78 | pdus.append( 79 | OutboundGsmPdu( 80 | seg_txt, 81 | recipient, 82 | csm_ref=csm_ref, 83 | csm_seq=seq+1, 84 | csm_total=num 85 | ) 86 | ) 87 | 88 | return pdus 89 | 90 | 91 | class SmsParseException(Exception): 92 | pass 93 | 94 | class SmsEncodeException(Exception): 95 | pass 96 | 97 | class GsmPdu(object): 98 | def __init__(self): 99 | self.is_csm = False 100 | self.csm_seq = None 101 | self.csm_total = None 102 | self.csm_ref = None 103 | self.address = None 104 | self.text = None 105 | self.pdu_string = None 106 | self.sent_ts = None 107 | 108 | def dump(self): 109 | """ 110 | Return a useful multiline rep of self 111 | 112 | """ 113 | header='Addressee: %s\nLength: %s\nSent %s' % \ 114 | (self.address, len(self.text), self.sent_ts) 115 | csm_info='' 116 | if self.is_csm: 117 | csm_info='\nCSM: %d of %d for Ref# %d' % (self.csm_seq, self.csm_total,self.csm_ref) 118 | return '%s%s\nMessage: \n%s\nPDU: %s' % (header, csm_info,self.text,self.pdu_string) 119 | 120 | 121 | class OutboundGsmPdu(GsmPdu): 122 | """ 123 | Formatted outbound PDU. Basically just 124 | a struct. 125 | 126 | Don't instantiate directly! Use 'get_outbound_pdus()' 127 | which will return a list of PDUs needed to 128 | send the message 129 | 130 | """ 131 | 132 | def __init__(self, text, recipient, csm_ref=None, csm_seq=None, csm_total=None): 133 | GsmPdu.__init__(self) 134 | 135 | self.address = recipient 136 | self.text = text 137 | self.gsm_text = None # if we are gsm, put the gsm encoded str here 138 | self.is_csm = csm_ref is not None 139 | self.csm_ref = ( None if csm_ref is None else int(csm_ref) ) 140 | self.csm_seq = ( None if csm_seq is None else int(csm_seq) ) 141 | self.csm_total = ( None if csm_total is None else int(csm_total) ) 142 | 143 | try: 144 | # following does two things: 145 | # 1. Raises exception if text cannot be encoded GSM 146 | # 2. measures the number of chars after encoding 147 | # since GSM is partially multi-byte, a string 148 | # in GSM can be longer than the obvious num of chars 149 | # e.g. 'hello' is 5 but 'hello^' is _7_ 150 | self.gsm_text=self.text.encode('gsm') 151 | num_chars=len(self.gsm_text) 152 | except: 153 | num_chars=len(self.text) 154 | 155 | if self.is_csm: 156 | max = MSG_LIMITS[self.encoding][1] 157 | else: 158 | max = MSG_LIMITS[self.encoding][0] 159 | 160 | if num_chars>max: 161 | raise SmsEncodeException('Text length too great') 162 | 163 | @property 164 | def encoding(self): 165 | return ( 'gsm' if self.is_gsm else 'ucs2' ) 166 | 167 | @property 168 | def is_gsm(self): 169 | return self.gsm_text is not None 170 | 171 | @property 172 | def is_ucs2(self): 173 | return not self.is_gsm 174 | 175 | def __get_pdu_string(self): 176 | # now put the PDU string together 177 | # first octet is SMSC info, 00 means get from stored on SIM 178 | pdu=['00'] 179 | # Next is 'SMS-SUBMIT First Octet' -- '11' means submit w/validity. 180 | # '51' means Concatendated SM w/validity 181 | pdu.append('51' if self.is_csm else '11') 182 | # Next is 'message' reference. '00' means phone can set this 183 | pdu.append('00') 184 | # now recipient number, first type 185 | if self.address[0]=='+': 186 | num = self.address[1:] 187 | type = '91' # international 188 | else: 189 | num = self.address 190 | type = 'A8' # national number 191 | 192 | # length 193 | num_len = len(num) 194 | # twiddle it 195 | num = _twiddle(num, False) 196 | pdu.append('%02X' % num_len) # length 197 | pdu.append(type) 198 | pdu.append(num) 199 | 200 | # now protocol ID 201 | pdu.append('00') 202 | 203 | # data coding scheme 204 | pdu.append('00' if self.is_gsm else '08') 205 | 206 | # validity period, just default to 4 days 207 | pdu.append('AA') 208 | 209 | # Now the fun! Make the user data (the text message) 210 | # Complications: 211 | # 1. If we are a CSM, need the CSM header 212 | # 2. If we are a CSM and GSM, need to pad the data 213 | padding = 0 214 | udh='' 215 | if self.is_csm: 216 | # data header always starts the same: 217 | # length: 5 octets '05' 218 | # type: CSM '00' 219 | # length of CSM info, 3 octets '03' 220 | udh='050003%02X%02X%02X' % (self.csm_ref, self.csm_total, self.csm_seq) 221 | 222 | if self.is_gsm: 223 | # padding is number of pits to pad-out beyond 224 | # the header to make everything land on a '7-bit' 225 | # boundary rather than 8-bit. 226 | # Can calculate as 7 - (UDH*8 % 7), but the UDH 227 | # is always 48, so padding is always 1 228 | padding = 1 229 | 230 | # now encode contents 231 | encoded_sm = ( 232 | _pack_septets(self.gsm_text, padding=padding) 233 | if self.is_gsm 234 | else self.text.encode('utf_16_be') 235 | ) 236 | encoded_sm = encoded_sm.encode('hex').upper() 237 | 238 | # and get the data length which is in septets 239 | # if GSM, and octets otherwise 240 | if self.is_gsm: 241 | # just take length of encoded gsm text 242 | # as each char becomes a septet when encoded 243 | udl = len(self.gsm_text) 244 | if len(udh)>0: 245 | udl+=7 # header is always 7 septets (inc. padding) 246 | else: 247 | # in this case just the byte length of content + header 248 | udl = (len(encoded_sm)+len(udh))/2 249 | 250 | # now add it all to the pdu 251 | pdu.append('%02X' % udl) 252 | pdu.append(udh) 253 | pdu.append(encoded_sm) 254 | return ''.join(pdu) 255 | 256 | def __set_pdu_string(self, val): 257 | pass 258 | pdu_string=property(__get_pdu_string, __set_pdu_string) 259 | 260 | class ReceivedGsmPdu(GsmPdu): 261 | """ 262 | A nice little class to parse a PDU and give you useful 263 | properties. 264 | 265 | Maybe one day it will let you set text and sender info and 266 | ask it to write itself out as a PDU! 267 | 268 | """ 269 | def __init__(self, pdu_str): 270 | GsmPdu.__init__(self) 271 | 272 | # hear are the properties that are set below in the 273 | # ugly parse code. 274 | 275 | self.tp_mms = False # more messages to send 276 | self.tp_sri = False # status report indication 277 | self.address = None # phone number of sender as string 278 | self.sent_ts = None # Datetime of when SMSC stamped the message, roughly when sent 279 | self.text = None # string of message contents 280 | self.pdu_string = pdu_str.upper() # original data as a string 281 | self.is_csm = False # is this one of a sequence of concatenated messages? 282 | self.csm_ref = 0 # reference number 283 | self.csm_seq = 0 # this chunks sequence num, 1-based 284 | self.csm_total = 0 # number of chunks total 285 | self.encoding = None # either 'gsm' or 'ucs2' 286 | 287 | self.__parse_pdu() 288 | 289 | 290 | """ 291 | This is truly hideous, just don't look below this line! 292 | 293 | It's times like this that I miss closed-compiled source... 294 | 295 | """ 296 | 297 | def __parse_pdu(self): 298 | pdu=self.pdu_string # make copy 299 | 300 | # grab smsc header, and throw away 301 | # length is held in first octet 302 | smsc_len,pdu=_consume_one_int(pdu) 303 | 304 | # consume smsc header 305 | c,pdu=_consume(pdu, smsc_len) 306 | 307 | # grab the deliver octect 308 | deliver_attrs,pdu=_consume_one_int(pdu) 309 | 310 | if deliver_attrs & 0x03 != 0: 311 | raise SmsParseException("Not a SMS-DELIVER, we ignore") 312 | 313 | self.tp_mms=deliver_attrs & 0x04 # more messages to send 314 | self.tp_sri=deliver_attrs & 0x20 # Status report indication 315 | tp_udhi=deliver_attrs & 0x40 # There is a user data header in the user data portion 316 | # get the sender number. 317 | # First the length which is given in 'nibbles' (half octets) 318 | # so divide by 2 and round up for odd 319 | sender_dec_len,pdu=_consume_one_int(pdu) 320 | sender_len=int(math.ceil(sender_dec_len/2.0)) 321 | 322 | # next is sender id type 323 | sender_type,pdu=_consume(pdu,1) 324 | 325 | # now the number itself, (unparsed) 326 | num,pdu=_consume(pdu,sender_len) 327 | 328 | # now parse the number 329 | self.address=_parse_phone_num(sender_type,num) 330 | 331 | # now the protocol id 332 | # we only understand SMS (0) 333 | tp_pid,pdu=_consume_one_int(pdu) 334 | if tp_pid >= 32: 335 | # can't deal 336 | print "TP PID: %s" % tp_pid 337 | raise SmsParseException("Not SMS protocol, bailing") 338 | 339 | # get and interpet DCS (char encoding info) 340 | self.encoding,pdu=_consume(pdu,1,_read_dcs) 341 | if self.encoding not in ['gsm','ucs2']: 342 | raise SmsParseException("Don't understand short message encoding") 343 | 344 | #get and interpret timestamp 345 | self.sent_ts,pdu=_consume(pdu,7,_read_ts) 346 | 347 | # ok, how long is ud? 348 | # note, if encoding is GSM this is num 7-bit septets 349 | # if ucs2, it's num bytes 350 | udl,pdu=_consume_one_int(pdu) 351 | 352 | # Now to deal with the User Data header! 353 | if tp_udhi: 354 | # yup, we got one, probably part of a 'concatenated short message', 355 | # what happens when you type too much text and your phone sends 356 | # multiple SMSs 357 | # 358 | # in fact this is the _only_ case we care about 359 | 360 | # get the header length 361 | udhl,pdu=_consume_decimal(pdu) 362 | 363 | # now loop through consuming the header 364 | # and looking to see if we are a csm 365 | i=0 366 | while i so strip it 413 | self.text=self.text.strip() 414 | 415 | # 416 | # And all the ugly helper functions 417 | # 418 | 419 | def _read_dcs(dcs): 420 | # make an int for masking 421 | dcs=int(dcs,16) 422 | 423 | # for an SMS, as opposed to a 'voice mail waiting' 424 | # indicator, first 4-bits must be zero 425 | if dcs & 0xf0 != 0: 426 | # not an SMS! 427 | return None 428 | 429 | dcs &= 0x0c # mask off everything but bits 3&2 430 | if dcs==0: 431 | return 'gsm' 432 | elif dcs==8: 433 | return 'ucs2' 434 | 435 | # not a type we know about, but should never get here 436 | return None 437 | 438 | def _B(slot): 439 | """Convert slot to Byte boundary""" 440 | return slot*2 441 | 442 | def _consume(seq, num,func=None): 443 | """ 444 | Consume the num of BYTES 445 | 446 | return a tuple of (consumed,remainder) 447 | 448 | func -- a function to call on the consumed. Result in tuple[0] 449 | 450 | """ 451 | num=_B(num) 452 | c=seq[:num] 453 | r=seq[num:] 454 | if func: 455 | c=func(c) 456 | return (c,r) 457 | 458 | def _consume_decimal(seq): 459 | """read 2 chars as a decimal""" 460 | return (int(seq[0:2],10),seq[2:]) 461 | 462 | def _consume_one_int(seq): 463 | """ 464 | Consumes one byte and returns int and remainder 465 | (int, remainder_of_seq) 466 | 467 | """ 468 | 469 | ints,remainder = _consume_bytes(seq,1) 470 | return (ints[0],remainder) 471 | 472 | def _consume_bytes(seq,num=1): 473 | """ 474 | consumes bytes for num ints (e.g. 2-chars per byte) 475 | coverts to int, returns tuple of ([byte...], remainder) 476 | 477 | """ 478 | 479 | bytes=[] 480 | for i in range(0,_B(num),2): 481 | bytes.append(int(seq[i:i+2],16)) 482 | 483 | return (bytes,seq[_B(num):]) 484 | 485 | def _twiddle(seq, decode=True): 486 | seq=seq.upper() # just in case 487 | result=list() 488 | for i in range(0,len(seq)-1,2): 489 | result.extend((seq[i+1],seq[i])) 490 | 491 | if len(result)0x80: 537 | neg = True 538 | tz-=0x80 539 | # now convert BACK to dec rep, 540 | # I know, ridiculous, but that's 541 | # the format... 542 | tz = int('%02X' % tz) 543 | tz_offset = tz/4 544 | if neg: 545 | tz_offset = -tz_offset 546 | tz_delta = datetime.timedelta(hours=tz_offset) 547 | 548 | # year is 2 digit! Yeah! Y2K problem again!! 549 | if yr<90: 550 | yr+=2000 551 | else: 552 | yr+=1900 553 | 554 | # python sucks with timezones, 555 | # so create UTC not using this offset 556 | dt = None 557 | try: 558 | # parse TS and adjust for TZ to get into UTC 559 | dt = datetime.datetime(yr,mo,dy,hr,mi,se, tzinfo=pytz.utc) - tz_delta 560 | except ValueError, ex: 561 | # Timestamp was bogus, set it to UTC now 562 | dt = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) 563 | 564 | return dt 565 | 566 | def _to_binary(n): 567 | s = "" 568 | for i in range(8): 569 | s = ("%1d" % (n & 1)) + s 570 | n >>= 1 571 | return s 572 | 573 | def _unpack_septets(seq,padding=0): 574 | """ 575 | this function taken from: 576 | http://offog.org/darcs/misccode/desms.py 577 | 578 | Thank you Adam Sampson ! 579 | """ 580 | 581 | # Unpack 7-bit characters 582 | msgbytes,r = _consume_bytes(seq,len(seq)/2) 583 | msgbytes.reverse() 584 | asbinary = ''.join(map(_to_binary, msgbytes)) 585 | if padding != 0: 586 | asbinary = asbinary[:-padding] 587 | chars = [] 588 | while len(asbinary) >= 7: 589 | chars.append(int(asbinary[-7:], 2)) 590 | asbinary = asbinary[:-7] 591 | return "".join(map(chr, chars)) 592 | 593 | def _pack_septets(str, padding=0): 594 | bytes=[ord(c) for c in str] 595 | bytes.reverse() 596 | asbinary = ''.join([_to_binary(b)[1:] for b in bytes]) 597 | # add padding 598 | for i in range(padding): 599 | asbinary+='0' 600 | 601 | # zero extend last octet if needed 602 | extra = len(asbinary) % 8 603 | if extra>0: 604 | for i in range(8-extra): 605 | asbinary='0'+asbinary 606 | 607 | # convert back to bytes 608 | bytes=[] 609 | for i in range(0,len(asbinary),8): 610 | bytes.append(int(asbinary[i:i+8],2)) 611 | bytes.reverse() 612 | return ''.join([chr(b) for b in bytes]) 613 | 614 | if __name__ == "__main__": 615 | # poor man's unit tests 616 | 617 | pdus = [ 618 | "0791227167830001040C912271479288270600902132210403001D31D90CE40E87E9F4FAF9CD06B9C3E6F75B5EA6BFE7F4B01B0402" 619 | "07912180958729F6040B814151733717F500009011709055902B0148", 620 | "07912180958729F6400B814151733717F500009070208044148AA0050003160201986FF719C47EBBCF20F6DB7D06B1DFEE3388FD769F41ECB7FB0C62BFDD6710FBED3E83D8ECB73B0D62BFDD67109BFD76A741613719C47EBBCF20F6DB7D06BCF61BC466BF41ECF719C47EBBCF20F6D", 621 | "07912180958729F6440B814151733717F500009070207095828AA00500030E0201986FF719C47EBBCF20F6DB7D06B1DFEE3388FD769F41ECB7FB0C62BFDD6710FBED3E83D8ECB7", 622 | "07912180958729F6040B814151733717F500009070103281418A09D93728FFDE940303", 623 | "07912180958729F6040B814151733717F500009070102230438A02D937", 624 | "0791227167830001040C912271271640910008906012024514001C002E004020AC00A300680065006C006C006F002000E900EC006B00F0", 625 | "07917283010010F5040BC87238880900F10000993092516195800AE8329BFD4697D9EC37", 626 | "0791448720900253040C914497035290960000500151614414400DD4F29C9E769F41E17338ED06", 627 | "0791448720003023440C91449703529096000050015132532240A00500037A020190E9339A9D3EA3E920FA1B1466B341E472193E079DD3EE73D85DA7EB41E7B41C1407C1CBF43228CC26E3416137390F3AABCFEAB3FAAC3EABCFEAB3FAAC3EABCFEAB3FAAC3EABCFEAB3FADC3EB7CFED73FBDC3EBF5D4416D9457411596457137D87B7E16438194E86BBCF6D16D9055D429548A28BE822BA882E6370196C2A8950E291E822BA88", 628 | "0791448720003023440C91449703529096000050015132537240310500037A02025C4417D1D52422894EE5B17824BA8EC423F1483C129BC725315464118FCDE011247C4A8B44", 629 | "07914477790706520414D06176198F0EE361F2321900005001610013334014C324350B9287D12079180D92A3416134480E", 630 | "0791448720003023440C91449703529096000050016121855140A005000301060190F5F31C447F83C8E5327CEE0221EBE73988FE0691CB65F8DC05028190F5F31C447F83C8E5327CEE028140C8FA790EA2BF41E472193E7781402064FD3C07D1DF2072B90C9FBB402010B27E9E83E86F10B95C86CF5D2064FD3C07D1DF2072B90C9FBB40C8FA790EA2BF41E472193E7781402064FD3C07D1DF2072B90C9FBB402010B27E9E83E8", 631 | "0791448720003023440C91449703529096000050016121850240A0050003010602DE2072B90C9FBB402010B27E9E83E86F10B95C86CF5D201008593FCF41F437885C2EC3E72E100884AC9FE720FA1B442E97E1731708593FCF41F437885C2EC3E72E100884AC9FE720FA1B442E97E17317080442D6CF7310FD0D2297CBF0B90B040221EBE73988FE0691CB65F8DC05028190F5F31C447F83C8E5327CEE028140C8FA790EA2BF41", 632 | "0791448720003023440C91449703529096000050016121854240A0050003010603C8E5327CEE0221EBE73988FE0691CB65F8DC05028190F5F31C447F83C8E5327CEE028140C8FA790EA2BF41E472193E7781402064FD3C07D1DF2072B90C9FBB402010B27E9E83E86F10B95C86CF5D201008593FCF41F437885C2EC3E72E10B27E9E83E86F10B95C86CF5D201008593FCF41F437885C2EC3E72E100884AC9FE720FA1B442E97E1", 633 | "0791448720003023400C91449703529096000050016121853340A005000301060540C8FA790EA2BF41E472193E7781402064FD3C07D1DF2072B90C9FBB402010B27E9E83E86F10B95C86CF5D201008593FCF41F437885C2EC3E72E100884AC9FE720FA1B442E97E17317080442D6CF7310FD0D2297CBF0B90B84AC9FE720FA1B442E97E17317080442D6CF7310FD0D2297CBF0B90B040221EBE73988FE0691CB65F8DC05028190", 634 | "0791448720003023440C914497035290960000500161218563402A050003010606EAE73988FE0691CB65F8DC05028190F5F31C447F83C8E5327CEE0281402010", 635 | ] 636 | """ 637 | print 638 | print '\n'.join([ 639 | p.dump() for p in get_outbound_pdus( 640 | u'\u5c71hellohello hellohello hellohello hellohello hellohello hellohello hellohello hellohello hellohello hellohello hellohello hellohello hellohello hellohello hellohello hellohello', 641 | '+14153773715' 642 | ) 643 | ]) 644 | """ 645 | 646 | for p in pdus: 647 | print '\n-------- Received ----------\nPDU: %s\n' % p 648 | rp = ReceivedGsmPdu(p) 649 | print rp.dump() 650 | op = get_outbound_pdus(rp.text, rp.address)[0] 651 | print '\nOut ------> \n' 652 | print op.dump() 653 | print '-----------------------------' 654 | 655 | 656 | 657 | 658 | 659 | 660 | 661 | -------------------------------------------------------------------------------- /pygsm/message/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 3 | 4 | 5 | from incoming import IncomingMessage 6 | from outgoing import OutgoingMessage 7 | -------------------------------------------------------------------------------- /pygsm/message/incoming.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 3 | 4 | 5 | import datetime 6 | import pytz 7 | 8 | 9 | class IncomingMessage(object): 10 | def __init__(self, device, sender, sent, text): 11 | 12 | # move the arguments into "private" attrs, 13 | # to try to prevent from from being modified 14 | self._device = device 15 | self._sender = sender 16 | self._sent = sent 17 | self._text = text 18 | 19 | # assume that the message was 20 | # received right now, since we 21 | # don't have an incoming buffer 22 | self._received = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) 23 | 24 | 25 | def __repr__(self): 26 | return "" %\ 27 | (self.sender, self.text) 28 | 29 | 30 | def respond(self, text): 31 | """Responds to this IncomingMessage by sending a message containing 32 | _text_ back to the sender via the modem that created this object.""" 33 | return self.device.send_sms(self.sender, text) 34 | 35 | 36 | @property 37 | def device(self): 38 | """Returns the pygsm.GsmModem device which received 39 | the SMS, and created this IncomingMessage object.""" 40 | return self._device 41 | 42 | @property 43 | def sender(self): 44 | """Returns the phone number of the originator of this IncomingMessage. 45 | It is stored directly as reported by the modem, so no assumptions 46 | can be made about it's format.""" 47 | return self._sender 48 | 49 | @property 50 | def sent(self): 51 | """Returns a datetime object containing the date and time that this 52 | IncomingMessage was sent, as reported by the modem. Sometimes, a 53 | network or modem will not report this field, so it will be None.""" 54 | return self._sent 55 | 56 | @property 57 | def text(self): 58 | """Returns the text contents of this IncomingMessage. It will usually 59 | be 160 characters or less, by virtue of being an SMS, but multipart 60 | messages can, technically, be up to 39015 characters long.""" 61 | return self._text 62 | 63 | @property 64 | def received(self): 65 | """Returns a datetime object containing the date and time that this 66 | IncomingMessage was created, which is a close aproximation of when 67 | the SMS was received.""" 68 | return self._received 69 | -------------------------------------------------------------------------------- /pygsm/message/outgoing.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 3 | 4 | 5 | class OutgoingMessage(object): 6 | pass 7 | -------------------------------------------------------------------------------- /pygsm/pdusmshandler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 encoding=utf-8 3 | 4 | import gsmpdu 5 | import traceback 6 | import errors, message 7 | import re 8 | from smshandler import SmsHandler 9 | 10 | class PduSmsHandler(SmsHandler): 11 | CMGL_MATCHER =re.compile(r'^\+CMGL:.*?$') 12 | CMGL_STATUS="0" 13 | 14 | def __init__(self, modem): 15 | SmsHandler.__init__(self, modem) 16 | 17 | def get_mode_cmd(self): 18 | return "AT+CMGF=0" 19 | 20 | def send_sms(self, recipient, text, max_messages = 255): 21 | """ 22 | Method will automatically split long 'text' into 23 | multiple SMSs up to max_messages. 24 | 25 | To enforce only a single SMS, set max_messages=1 26 | 27 | Raises 'ValueError' if text will not fit in max_messages 28 | """ 29 | pdus = gsmpdu.get_outbound_pdus(text, recipient) 30 | if len(pdus) > max_messages: 31 | raise ValueError( 32 | 'Max_message is %d and text requires %d messages' % 33 | (max_messages, len(pdus)) 34 | ) 35 | 36 | for pdu in pdus: 37 | self._send_pdu(pdu) 38 | return True 39 | 40 | def _send_pdu(self, pdu): 41 | # outer try to catch any error and make sure to 42 | # get the modem out of 'waiting for data' mode 43 | try: 44 | # accesing the property causes the pdu_string 45 | # to be generated, so do once and cache 46 | pdu_string = pdu.pdu_string 47 | 48 | # try to catch write timeouts 49 | try: 50 | # content length is in bytes, so half PDU minus 51 | # the first blank '00' byte 52 | self.modem.command( 53 | 'AT+CMGS=%d' % (len(pdu_string)/2 - 1), 54 | read_timeout=1 55 | ) 56 | 57 | # if no error is raised within the timeout period, 58 | # and the text-mode prompt WAS received, send the 59 | # sms text, wait until it is accepted or rejected 60 | # (text-mode messages are terminated with ascii char 26 61 | # "SUBSTITUTE" (ctrl+z)), and return True (message sent) 62 | except errors.GsmReadTimeoutError, err: 63 | if err.pending_data[0] == ">": 64 | self.modem.command(pdu_string, write_term=chr(26)) 65 | return True 66 | 67 | # a timeout was raised, but no prompt nor 68 | # error was received. i have no idea what 69 | # is going on, so allow the error to propagate 70 | else: 71 | raise 72 | 73 | finally: 74 | pass 75 | 76 | # for all other errors... 77 | # (likely CMS or CME from device) 78 | except Exception: 79 | traceback.print_exc() 80 | # whatever went wrong, break out of the 81 | # message prompt. if this is missed, all 82 | # subsequent writes will go into the message! 83 | self.modem.break_out_of_prompt() 84 | 85 | # rule of thumb: pyGSM is meant to be embedded, 86 | # so DO NOT EVER allow exceptions to propagate 87 | # (obviously, this sucks. there should be an 88 | # option, at least, but i'm being cautious) 89 | return None 90 | 91 | def parse_incoming_message(self, header_line, line): 92 | pdu = None 93 | try: 94 | pdu = gsmpdu.ReceivedGsmPdu(line) 95 | except Exception, ex: 96 | traceback.print_exc(ex) 97 | self.modem._log('Error parsing PDU: %s' % line) 98 | 99 | return self._process_incoming_pdu(pdu) 100 | 101 | def parse_stored_messages(self, lines): 102 | # loop through all the lines attempting to match CMGL lines (the header) 103 | # and then match NOT CMGL lines (the content) 104 | # need to seed the loop first 'cause Python no like 'until' loops 105 | pdu_lines=[] 106 | messages = [] 107 | m = None 108 | if len(lines)>0: 109 | m=self.CMGL_MATCHER.match(lines[0]) 110 | 111 | while len(lines)>0: 112 | if m is None: 113 | # couldn't match OR no text data following match 114 | raise(errors.GsmReadError()) 115 | 116 | # if here, we have a match AND text 117 | # start by popping the header (which we have stored in the 'm' 118 | # matcher object already) 119 | lines.pop(0) 120 | 121 | # now loop through, popping content until we get 122 | # the next CMGL or out of lines 123 | while len(lines)>0: 124 | m=self.CMGL_MATCHER.match(lines[0]) 125 | if m is not None: 126 | # got another header, get out 127 | break 128 | else: 129 | # HACK: For some reason on the multitechs the first 130 | # PDU line has the second '+CMGL' response tacked on 131 | # this may be a multitech bug or our bug in 132 | # reading the responses. For now, split the response 133 | # on +CMGL 134 | line = lines.pop(0) 135 | line, cmgl, rest = line.partition('+CMGL') 136 | if len(cmgl)>0: 137 | lines.insert(0,'%s%s' % (cmgl,rest)) 138 | pdu_lines.append(line) 139 | 140 | # now create and process PDUs 141 | for pl in pdu_lines: 142 | try: 143 | pdu = gsmpdu.ReceivedGsmPdu(pl) 144 | msg = self._process_incoming_pdu(pdu) 145 | if msg is not None: 146 | messages.append(msg) 147 | 148 | except Exception, ex: 149 | traceback.print_exc(ex) 150 | self.modem._log('Error parsing PDU: %s' % pl) # TODO log 151 | 152 | return messages 153 | 154 | def _incoming_pdu_to_msg(self, pdu): 155 | if pdu.text is None or len(pdu.text)==0: 156 | self.modem._log('Blank inbound text, ignoring') 157 | return 158 | 159 | msg = message.IncomingMessage(self, 160 | pdu.address, 161 | pdu.sent_ts, 162 | pdu.text) 163 | return msg 164 | 165 | def _process_incoming_pdu(self, pdu): 166 | if pdu is None: 167 | return None 168 | 169 | # is this a multi-part (concatenated short message, csm)? 170 | if pdu.is_csm: 171 | # process pdu will either 172 | # return a 'super' pdu with the entire 173 | # message (if this is the last segment) 174 | # or None if there are more segments coming 175 | pdu = self._process_csm(pdu) 176 | 177 | if pdu is not None: 178 | return self._incoming_pdu_to_msg(pdu) 179 | return None 180 | 181 | def _process_csm(self, pdu): 182 | if not pdu.is_csm: 183 | return pdu 184 | 185 | # self.multipart is a dict of dicts of dicts 186 | # holding all parts of messages by sender 187 | # e.g. { '4155551212' : { 0: { seq1: pdu1, seq2: pdu2{ } } 188 | # 189 | if pdu.address not in self.multipart: 190 | self.multipart[pdu.address]={} 191 | 192 | sender_msgs=self.multipart[pdu.address] 193 | if pdu.csm_ref not in sender_msgs: 194 | sender_msgs[pdu.csm_ref]={} 195 | 196 | # these are all the pdus in this 197 | # sequence we've recived 198 | received = sender_msgs[pdu.csm_ref] 199 | received[pdu.csm_seq]=pdu 200 | 201 | # do we have them all? 202 | if len(received)==pdu.csm_total: 203 | pdus=received.values() 204 | pdus.sort(key=lambda x: x.csm_seq) 205 | text = ''.join([p.text for p in pdus]) 206 | 207 | # now make 'super-pdu' out of the first one 208 | # to hold the full text 209 | super_pdu = pdus[0] 210 | super_pdu.csm_seq = 0 211 | super_pdu.csm_total = 0 212 | super_pdu.pdu_string = None 213 | super_pdu.text = text 214 | super_pdu.encoding = None 215 | 216 | del sender_msgs[pdu.csm_ref] 217 | 218 | return super_pdu 219 | else: 220 | return None 221 | -------------------------------------------------------------------------------- /pygsm/smshandler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 encoding=utf-8 3 | 4 | import re 5 | 6 | ERR_MSG = "Must use one of concrete subclasses:PduSmsHandler or TextSmsHandler" 7 | 8 | class SmsHandler(object): 9 | 10 | def __init__(self,modem): 11 | self.modem = modem 12 | self.multipart = {} 13 | 14 | def send_sms(self, recipient, text, max_messages = 255): 15 | """ 16 | Note: Only PDU mode handler respects 'max_messages' 17 | 18 | """ 19 | raise Exception(ERR_MSG) 20 | 21 | def get_mode_cmd(self): 22 | raise Exception(ERR_MSG) 23 | 24 | # returns a list of messages 25 | def parse_stored_messages(self, lines): 26 | raise Exception(ERR_MSG) 27 | 28 | # returns a single message 29 | def parse_incoming_message(self, header_line, line): 30 | raise Exception(ERR_MSG) -------------------------------------------------------------------------------- /pygsm/textsmshandler.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 encoding=utf-8 3 | 4 | import errors, traceback, message 5 | import re, datetime, time 6 | import StringIO 7 | import pytz 8 | from smshandler import SmsHandler 9 | 10 | class TextSmsHandler(SmsHandler): 11 | SCTS_FMT = "%y/%m/%d,%H:%M:%S" 12 | CMGL_MATCHER=re.compile(r'^\+CMGL: (\d+),"(.+?)","(.+?)",*?,"(.+?)".*?$') 13 | CMGL_STATUS='"REC UNREAD"' 14 | 15 | def __init__(self, modem): 16 | SmsHandler.__init__(self, modem) 17 | 18 | def get_mode_cmd(self): 19 | return "AT+CMGF=1" 20 | 21 | def send_sms(self, recipient, text, max_messages = 255): 22 | """Sends an SMS to _recipient_ containing _text_. Some networks 23 | will automatically chunk long messages into multiple parts, 24 | and reassembled them upon delivery, but some will silently 25 | drop them. At the moment, pyGSM does nothing to avoid this, 26 | so try to keep _text_ under 160 characters. 27 | 28 | Currently 'max_messages' is ignored 29 | """ 30 | 31 | old_mode = None 32 | try: 33 | try: 34 | # cast the text to a string, to check that 35 | # it doesn't contain non-ascii characters 36 | try: 37 | text = str(text) 38 | 39 | # uh-oh. unicode ahoy 40 | except UnicodeEncodeError: 41 | 42 | # fetch and store the current mode (so we can 43 | # restore it later), and override it with UCS2 44 | csmp = self.modem.query("AT+CSMP?", "+CSMP:") 45 | if csmp is not None: 46 | old_mode = csmp.split(",") 47 | mode = old_mode[:] 48 | mode[3] = "8" 49 | 50 | # enable hex mode, and set the encoding 51 | # to UCS2 for the full character set 52 | self.modem.command('AT+CSCS="HEX"') 53 | self.modem.command("AT+CSMP=%s" % ",".join(mode)) 54 | text = text.encode("utf-16").encode("hex") 55 | 56 | # initiate the sms, and give the device a second 57 | # to raise an error. unfortunately, we can't just 58 | # wait for the "> " prompt, because some modems 59 | # will echo it FOLLOWED BY a CMS error 60 | result = self.modem.command( 61 | 'AT+CMGS=\"%s\"' % (recipient), 62 | read_timeout=1) 63 | 64 | # if no error is raised within the timeout period, 65 | # and the text-mode prompt WAS received, send the 66 | # sms text, wait until it is accepted or rejected 67 | # (text-mode messages are terminated with ascii char 26 68 | # "SUBSTITUTE" (ctrl+z)), and return True (message sent) 69 | except errors.GsmReadTimeoutError, err: 70 | if err.pending_data[0] == ">": 71 | self.modem.command(text, write_term=chr(26)) 72 | return True 73 | 74 | # a timeout was raised, but no prompt nor 75 | # error was received. i have no idea what 76 | # is going on, so allow the error to propagate 77 | else: 78 | raise 79 | 80 | # for all other errors... 81 | # (likely CMS or CME from device) 82 | except Exception, err: 83 | traceback.print_exc(err) 84 | # whatever went wrong, break out of the 85 | # message prompt. if this is missed, all 86 | # subsequent writes will go into the message! 87 | self.modem.break_out_of_prompt() 88 | 89 | # rule of thumb: pyGSM is meant to be embedded, 90 | # so DO NOT EVER allow exceptions to propagate 91 | # (obviously, this sucks. there should be an 92 | # option, at least, but i'm being cautious) 93 | return None 94 | 95 | finally: 96 | 97 | # if the mode was overridden above, (if this 98 | # message contained unicode), switch it back 99 | if old_mode is not None: 100 | self.modem.command("AT+CSMP=%s" % ",".join(old_mode)) 101 | self.modem.command('AT+CSCS="GSM"') 102 | return True 103 | 104 | # returns a list of messages 105 | def parse_stored_messages(self, lines): 106 | # loop through all the lines attempting to match CMGL lines (the header) 107 | # and then match NOT CMGL lines (the content) 108 | # need to seed the loop first 109 | messages = [] 110 | if len(lines)>0: 111 | m=self.CMGL_MATCHER.match(lines[0]) 112 | 113 | while len(lines)>0: 114 | if m is None: 115 | # couldn't match OR no text data following match 116 | raise(errors.GsmReadError()) 117 | 118 | # if here, we have a match AND text 119 | # start by popping the header (which we have stored in the 'm' 120 | # matcher object already) 121 | lines.pop(0) 122 | 123 | # now put the captures into independent vars 124 | index, status, sender, timestamp = m.groups() 125 | 126 | # now loop through, popping content until we get 127 | # the next CMGL or out of lines 128 | msg_buf=StringIO.StringIO() 129 | while len(lines)>0: 130 | m=self.CMGL_MATCHER.match(lines[0]) 131 | if m is not None: 132 | # got another header, get out 133 | break 134 | else: 135 | msg_buf.write(lines.pop(0)) 136 | 137 | # get msg text 138 | msg_text=msg_buf.getvalue().strip() 139 | 140 | # now create message 141 | messages.append(self._incoming_to_msg(timestamp,sender,msg_text)) 142 | return messages 143 | 144 | # returns a single message 145 | def parse_incoming_message(self, header_line, text): 146 | # since this line IS a CMT string (an incoming 147 | # SMS), parse it and store it to deal with later 148 | m = re.match(r'^\+CMT: "(.+?)",.*?,"(.+?)".*?$', header_line) 149 | sender = "" 150 | timestamp = None 151 | if m is not None: 152 | 153 | # extract the meta-info from the CMT line, 154 | # and the message from the FOLLOWING line 155 | sender, timestamp = m.groups() 156 | 157 | # multi-part messages begin with ASCII 130 followed 158 | # by "@" (ASCII 64). TODO: more docs on this, i wrote 159 | # this via reverse engineering and lost my notes 160 | if (ord(text[0]) == 130) and (text[1] == "@"): 161 | part_text = text[7:] 162 | 163 | # ensure we have a place for the incoming 164 | # message part to live as they are delivered 165 | if sender not in self.multipart: 166 | self.multipart[sender] = [] 167 | 168 | # append THIS PART 169 | self.multipart[sender].append(part_text) 170 | 171 | # abort if this is not the last part 172 | if ord(text[5]) != 173: 173 | return None 174 | 175 | # last part, so switch out the received 176 | # part with the whole message, to be processed 177 | # below (the sender and timestamp are the same 178 | # for all parts, so no change needed there) 179 | text = "".join(self.multipart[sender]) 180 | del self.multipart[sender] 181 | 182 | return self._incoming_to_msg(timestamp, sender, text) 183 | 184 | def _incoming_to_msg(self, timestamp, sender, text): 185 | 186 | # since neither message notifications nor messages 187 | # fetched from storage give any indication of their 188 | # encoding, we're going to have to guess. if the 189 | # text has a multiple-of-four length and starts 190 | # with a UTF-16 Byte Order Mark, try to decode it 191 | # into a unicode string 192 | try: 193 | if (len(text) % 4 == 0) and (len(text) > 0): 194 | bom = text[:4].lower() 195 | if bom == "fffe"\ 196 | or bom == "feff": 197 | 198 | # decode the text into a unicode string, 199 | # so developers embedding pyGSM need never 200 | # experience this confusion and pain 201 | text = text.decode("hex").decode("utf-16") 202 | 203 | # oh dear. it looked like hex-encoded utf-16, 204 | # but wasn't. who sends a message like that?! 205 | except: 206 | pass 207 | 208 | # create and store the IncomingMessage object 209 | time_sent = None 210 | if timestamp is not None: 211 | time_sent = self._parse_incoming_timestamp(timestamp) 212 | return message.IncomingMessage(self, sender, time_sent, text) 213 | 214 | def _parse_incoming_timestamp(self, timestamp): 215 | """Parse a Service Center Time Stamp (SCTS) string into a Python datetime 216 | object, or None if the timestamp couldn't be parsed. The SCTS format does 217 | not seem to be standardized, but looks something like: YY/MM/DD,HH:MM:SS.""" 218 | 219 | # timestamps usually have trailing timezones, measured 220 | # in 15-minute intervals (?!), which is not handled by 221 | # python's datetime lib. if _this_ timezone does, chop 222 | # it off, and note the actual offset in minutes 223 | tz_pattern = r"([-+])(\d+)$" 224 | m = re.search(tz_pattern, timestamp) 225 | if m is not None: 226 | timestamp = re.sub(tz_pattern, "", timestamp) 227 | tz_offset = datetime.timedelta(minutes=int(m.group(2)) * 15) 228 | if m.group(1)=='-': 229 | tz_offset = -tz_offset 230 | 231 | # we won't be modifying the output, but 232 | # still need an empty timedelta to subtract 233 | else: 234 | tz_offset = datetime.timedelta() 235 | 236 | # attempt to parse the (maybe modified) timestamp into 237 | # a time_struct, and convert it into a datetime object 238 | try: 239 | time_struct = time.strptime(timestamp, self.SCTS_FMT) 240 | dt = datetime.datetime(*time_struct[:6]) 241 | dt.replace(tzinfo=pytz.utc) 242 | 243 | # patch the time to represent UTC, since 244 | dt-=tz_offset 245 | return dt 246 | 247 | # if the timestamp couldn't be parsed, we've encountered 248 | # a format the pyGSM doesn't support. this sucks, but isn't 249 | # important enough to explode like RubyGSM does 250 | except ValueError: 251 | traceback.print_exc() 252 | return None 253 | -------------------------------------------------------------------------------- /pygsm_demo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # vim: ai ts=4 sts=4 et sw=4 3 | 4 | import os 5 | import sys 6 | import time 7 | 8 | if __name__ == "__main__": 9 | if len(sys.argv) >= 2: 10 | # put 'lib' in working path to grab pygsm from same dir 11 | # for launch out of development directory 12 | libdir = os.path.join(os.path.dirname(sys.argv[0]),'lib') 13 | sys.path.insert(0,libdir) 14 | from pygsm.gsmmodem import GsmModem 15 | 16 | # the first argument is SERIAL PORT 17 | # (required, since we have no autodetect yet) 18 | port = sys.argv[1] 19 | 20 | # all subsequent options are parsed as key=value 21 | # pairs, to be passed on to GsmModem.__init__ as 22 | # kwargs, to configure the serial connection 23 | conf = dict([ 24 | arg.split("=", 1) 25 | for arg in sys.argv[2:] 26 | if arg.find("=") > -1 27 | ]) 28 | 29 | # dump the connection settings 30 | print "pyGSM Demo App" 31 | print " Port: %s" % (port) 32 | print " Config: %r" % (conf) 33 | print 34 | 35 | # connect to the modem (this might hang 36 | # if the connection settings are wrong) 37 | print "Connecting to GSM Modem..." 38 | modem = GsmModem(port=port, **conf) 39 | print "Waiting for incoming messages..." 40 | 41 | # check for new messages every two 42 | # seconds for the rest of forever 43 | while True: 44 | msg = modem.next_message() 45 | 46 | # we got a message! respond with 47 | # something useless, as an example 48 | if msg is not None: 49 | print "Got Message: %r" % msg 50 | msg.respond("Received: %d characters '%s'" % 51 | (len(msg.text),msg.text)) 52 | 53 | # no messages? wait a couple 54 | # of seconds and try again 55 | else: time.sleep(2) 56 | 57 | # the serial port must be provided 58 | # we're not auto-detecting, yet 59 | else: 60 | print "Usage: python -m pygsm.gsmmodem PORT [OPTIONS]" 61 | -------------------------------------------------------------------------------- /sms.py: -------------------------------------------------------------------------------- 1 | #encoding: utf-8 2 | 3 | class SmsService: 4 | 5 | def get_messages(self): 6 | pass 7 | 8 | def send(self, text): 9 | pass 10 | 11 | def start(self): 12 | '''启动服务''' 13 | pass 14 | 15 | def stop(self): 16 | '''停止''' 17 | pass 18 | -------------------------------------------------------------------------------- /wallet.py: -------------------------------------------------------------------------------- 1 | #encoding: utf-8 2 | 3 | import json 4 | import httplib 5 | import datetime 6 | import cookielib 7 | import urllib, urllib2 8 | import random 9 | import logging 10 | import os 11 | import lxml.html 12 | import lxml.etree 13 | 14 | from info import * 15 | import config 16 | 17 | SATOSHI_PER_BTC = 100000000 18 | 19 | class Wallet: 20 | __base_url = 'https://blockchain.info/zh-cn/merchant' 21 | __timeout = 30 22 | 23 | def __init__(self): 24 | self._config = config.configuration['wallet'] 25 | self.userid = self._config['userid'] 26 | self.password = self._config['password'] 27 | self.transfer_fee = 0.0005 28 | 29 | def balance(self): 30 | '''URL: 31 | https://blockchain.info/zh-cn/merchant/$guid/balance?password=$main_password 32 | ''' 33 | url = '{0}/{1}/balance?password={2}'.format(Wallet.__base_url, self.userid, self.password) 34 | request = urllib2.Request(url) 35 | response = urllib2.urlopen(request, timeout=Wallet.__timeout) 36 | balance_data = json.loads(response.read()) 37 | response.close() 38 | return float(balance_data['balance']) / float(SATOSHI_PER_BTC) 39 | 40 | def withdraw(self, address, btc): 41 | ''' 42 | URL: 43 | https://blockchain.info/zh-cn/merchant/$guid/payment?password=$main_password&second_password=$second_password&to=$address&amount=$amount&from=$from&shared=$shared&fee=$fee¬e=$note 44 | ''' 45 | satoshi = btc * SATOSHI_PER_BTC 46 | url = '{0}/{1}/payment?password={2}&to={3}&amount={4}'.format( 47 | Wallet.__base_url, self.userid, self.password, address, satoshi) 48 | request = urllib2.Request(url) 49 | response = urllib2.urlopen(request, timeout=Wallet.__timeout) 50 | response_data = json.loads(response.read()) 51 | response.close() 52 | return response_data['tx_hash'] 53 | --------------------------------------------------------------------------------