├── .gitignore ├── PROTOCOL.md ├── README.md ├── REMOTE.md └── ambarpc.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | *.py[co] 3 | -------------------------------------------------------------------------------- /PROTOCOL.md: -------------------------------------------------------------------------------- 1 | Amba JSON Protocol 2 | ================== 3 | 4 | Messages are JSON objects sent back and forth, without any kind of delimiter in 5 | between, through a TCP socket. In case of Xiaomi Yi `network_message_daemon` is 6 | running on ports 8787 and 7878. Actual message parsing is happening in 7 | `libamba_msg_framework.so.1` and `libmsgprocess.so.2`, and most of the requests 8 | are forwarded into uITRON. 9 | 10 | Every packet contains `msg_id` key depicting type of message sent, in most 11 | cases being an rpc method identifier. Client packets also need `token` value, 12 | which is handed by `MSG_AUTHENTICATE (257)` (or `AMBA_START_SESSION`, following 13 | the naming in an android app) call. Messages sent from server to client contain 14 | `msg_id` as mentioned before, and `rval`, which is a response code. Packets in 15 | both directions can contain arbitrary keys when needed, for client-to-server 16 | direction `param` and `type` are most common. 17 | 18 | `msg_id` 7 (`MSG_STATUS`) messages can be sent from server to client 19 | out-of-order and depict some kind of event. 20 | 21 | 22 | 23 | Known `rval`s 24 | ------------- 25 | 26 | - -23 - Invalid `msg_id` 27 | - -21 - Value unchanged (`config_get`) 28 | - -4 - Session invalid, new token is needed 29 | - 0 - Success 30 | 31 | Known `msg_id`s 32 | --------------- 33 | 34 | ### 257 - Session start / Authenticate 35 | Returns new token in param field 36 | 37 | DEBUG:__main__:>> {'token': 0, 'msg_id': 257} 38 | DEBUG:__main__:<< {u'rval': 0, u'msg_id': 257, u'param': 8} 39 | 40 | ### 258 - Session stop 41 | Revokes current token 42 | 43 | ### 13 - Battery status 44 | Returns whether device is running off battery or USB adapter, and current 45 | battery charge status. 46 | 47 | DEBUG:__main__:>> {'token': 8, 'msg_id': 13} 48 | DEBUG:__main__:<< {u'type': u'adapter', u'rval': 0, u'msg_id': 13, u'param': u'86'} 49 | # Camera is running off USB adapter right now, 86% charge 50 | 51 | ### 1 - Config get 52 | Returns current config value for key `type` 53 | 54 | DEBUG:__main__:>> {'token': 8, 'msg_id': 1, 'type': 'camera_clock'} 55 | DEBUG:__main__:<< {u'type': u'camera_clock', u'rval': 0, u'msg_id': 1, u'param': u'2016-03-28 02:00:18'} 56 | 57 | ### 2 - Config set 58 | Sets config value for key `type` to `param` 59 | 60 | DEBUG:__main__:>> {'token': 8, 'msg_id': 2, 'type': 'osd_enable', 'param': 'on'} 61 | DEBUG:__main__:<< {u'rval': 0, u'msg_id': 2} 62 | 63 | ### 3 - Config get all 64 | Returns all configuration values 65 | 66 | DEBUG:__main__:>> {'token': 8, 'msg_id': 3} 67 | DEBUG:__main__:<< {u'rval': 0, u'msg_id': 3, u'param': [{u'camera_clock': u'2016-03-28 02:02:09'}, {u'video_standard': u'NTSC'}, ...]} 68 | 69 | ### 4 - Storage format 70 | Formats SD card 71 | 72 | ### 5 - Storage usage 73 | Returns current storage usage and size in kilobytes, `type` request value can be either `total` (for total storage size), or `free` (for free storage size) 74 | 75 | DEBUG:__main__:>> {'token': 8, 'msg_id': 5, 'type': 'total'} 76 | DEBUG:__main__:<< {u'rval': 0, u'msg_id': 5, u'param': 7774208} 77 | 78 | ### 259 - Preview start 79 | Starts preview available on `rtmp://ADDRESS/live` 80 | 81 | DEBUG:__main__:>> {'token': 8, 'msg_id': 259, 'param': 'none_force'} 82 | DEBUG:__main__:<< {u'rval': 0, u'msg_id': 259} 83 | 84 | ### 260 - Preview stop 85 | Stops preview 86 | 87 | DEBUG:__main__:>> {'token': 8, 'msg_id': 260} 88 | DEBUG:__main__:<< {u'rval': 0, u'msg_id': 260} 89 | 90 | ### 513 - Record start 91 | 92 | DEBUG:__main__:>> {'token': 8, 'msg_id': 513} 93 | DEBUG:__main__:<< {u'rval': 0, u'msg_id': 513} 94 | DEBUG:__main__:<< {u'msg_id': 7, u'type': u'start_video_record'} 95 | 96 | ### 514 - Record stop 97 | 98 | DEBUG:__main__:>> {'token': 8, 'msg_id': 514} 99 | DEBUG:__main__:<< {u'rval': 0, u'msg_id': 514} 100 | DEBUG:__main__:<< {u'msg_id': 7, u'type': u'video_record_complete', u'param': u'/tmp/fuse_d/DCIM/101MEDIA/YDXJ0420.mp4'} 101 | 102 | ### 769 - Capture photo 103 | Captures photo. Works regardless of current mode, but camera can't be in recording state 104 | 105 | DEBUG:__main__:>> {'token': 8, 'msg_id': 769} 106 | DEBUG:__main__:<< {u'msg_id': 7, u'type': u'start_photo_capture', u'param': u'precise quality;off'} 107 | DEBUG:__main__:<< {u'msg_id': 7, u'type': u'precise_capture_data_ready'} 108 | DEBUG:__main__:<< {u'msg_id': 7, u'type': u'photo_taken', u'param': u'/tmp/fuse_d/DCIM/101MEDIA/YDXJ0421.jpg'} 109 | DEBUG:__main__:<< {u'rval': 0, u'msg_id': 514} 110 | 111 | *More calls can be found in `ambarpc.py` for now* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | AmbaRPC 2 | ======= 3 | Simple Ambarella API (`network_message_daemon`) client. Compatible and mostly 4 | tested on Xiaoyi/Xiaomi Yi Camera. Client is mostly self-explanatory, with 5 | included example when run directly. 6 | 7 | License 8 | ------- 9 | MIT 10 | 11 | 12 | Random links 13 | ------------ 14 | * https://github.com/funneld/XiaomiYi/tree/master/autoexec.ash 15 | * https://github.com/vogloblinsky/elmo-qbic-4-cam-rig-manager/blob/master/API_Reverse_engineering.md 16 | * https://github.com/kerenmac/Xiaomi-Yi 17 | * https://github.com/deltaflyer4747/Xiaomi_Yi 18 | -------------------------------------------------------------------------------- /REMOTE.md: -------------------------------------------------------------------------------- 1 | Bluetooth Remote 2 | ================ 3 | 4 | Bluetooth remote most probably communicates through Bluetooth Low Energy (based 5 | on reports from people that it should work fine for a year without charging) 6 | 7 | Discovery mode is enabled in camera by double-pressing wifi button. Discovery 8 | happens in `.text:00011734` in `app_ble`. Its log is saved in 9 | `/tmp/fuse/1970-1-1.log`. App expects BD address to begin with 10 | `20:73:80`. If any BLE device with non-matching address is found, 11 | `invalid remote ble control device` message with bdaddr and rssi is logged. 12 | -------------------------------------------------------------------------------- /ambarpc.py: -------------------------------------------------------------------------------- 1 | import blinker 2 | import json 3 | import logging 4 | import pprint 5 | import socket 6 | import time 7 | import hashlib 8 | 9 | 10 | # Known msg_ids 11 | MSG_CONFIG_GET = 1 # AMBA_GET_SETTING 12 | MSG_CONFIG_SET = 2 13 | MSG_CONFIG_GET_ALL = 3 14 | 15 | MSG_FORMAT = 4 16 | MSG_STORAGE_USAGE = 5 17 | 18 | MSG_STATUS = 7 19 | MSG_BATTERY = 13 20 | 21 | MSG_AUTHENTICATE = 257 22 | MSG_PREVIEW_START = 259 23 | MSG_PREVIEW_STOP = 260 # 258 previously, which ends current session 24 | 25 | MSG_RECORD_START = 513 26 | MSG_RECORD_STOP = 514 27 | MSG_CAPTURE = 769 28 | 29 | MSG_RECORD_TIME = 515 # Returns param: recording length 30 | 31 | # File management messages 32 | MSG_RM = 1281 # Param: path, supports wildcards 33 | MSG_LS = 1282 # (Optional) Param: directory (path to file kills the server) 34 | MSG_CD = 1283 # Param: directory, Returns pwd: current directory 35 | MSG_MEDIAINFO = 1026 # Param: filename, returns media_type, date, duration, 36 | # framerate, size, resolution, ... 37 | 38 | MSG_DIGITAL_ZOOM = 15 # type: current returns current zoom value 39 | MSG_DIGITAL_ZOOM_SET = 14 # type: fast, param: zoom level 40 | 41 | # Not supported yet 42 | MSG_DOWNLOAD_CHUNK = 1285 # param, offset, fetch_size 43 | MSG_DOWNLOAD_CANCEL = 1287 # param 44 | MSG_UPLOAD_CHUNK = 1286 # md5sum, param (path), size, offset 45 | 46 | # Other random msg ids found throughout app / binaries 47 | MSG_GET_SINGLE_SETTING_OPTIONS = 9 # ~same as MSG_CONFIG_GET_ALL with param 48 | MSG_SD_SPEED = 0x1000002 # Returns rval: -13 49 | MSG_SD_TYPE = 0x1000001 # Returns param: sd_hc 50 | MSG_GET_THUMB = 1025 # Type: thumb, param: path, returns -21 if already exists 51 | 52 | # No response...? 53 | MSG_QUERY_SESSION_HOLDER = 1793 # ?? 54 | 55 | MSG_UNKNOW = 0x5000001 # likely non-existent 56 | 57 | MSG_BITRATE = 16 # Unknown syntax, param 58 | 59 | # Sends wifi_will_shutdown event after that, takes a looong time (up to 2 60 | # minutes) 61 | MSG_RESTART_WIFI = 0x1000009 62 | 63 | MSG_SET_SOFTAP_CONFIG = 0x2000001 64 | MSG_GET_SOFTAP_CONFIG = 0x2000002 65 | MSG_RESTART_WEBSERVER = 0x2000003 66 | 67 | MSG_UPGRADE = 0x1000003 # param: upgrade file 68 | 69 | logger = logging.getLogger(__name__) 70 | 71 | 72 | class TimeoutException(Exception): 73 | pass 74 | 75 | 76 | class RPCError(Exception): 77 | pass 78 | 79 | 80 | class AmbaRPCClient(object): 81 | address = None 82 | port = None 83 | 84 | _decoder = None 85 | _buffer = None 86 | _socket = None 87 | 88 | token = None 89 | 90 | def __init__(self, address='192.168.42.1', port=7878): 91 | self.address = address 92 | self.port = port 93 | 94 | self._decoder = json.JSONDecoder() 95 | self._buffer = "" 96 | 97 | ns = blinker.Namespace() 98 | self.raw_message = ns.signal('raw-message') 99 | self.event = ns.signal('event') 100 | 101 | def connect(self): 102 | """Connects to RPC service""" 103 | self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 104 | logger.info('Connecting...') 105 | self._socket.connect((self.address, self.port)) 106 | self._socket.settimeout(1) 107 | logger.info('Connected') 108 | 109 | def authenticate(self): 110 | """Fetches auth token used for all the requests""" 111 | self.token = 0 112 | self.token = self.call(MSG_AUTHENTICATE)['param'] 113 | logger.info('Authenticated') 114 | 115 | def send_message(self, msg_id, **kwargs): 116 | """Sends a single RPC message""" 117 | kwargs.setdefault('msg_id', msg_id) 118 | kwargs.setdefault('token', self.token) 119 | logger.debug('[%s] >> %r', self.address, kwargs) 120 | 121 | self._socket.send(json.dumps(kwargs)) 122 | 123 | def parse_message(self): 124 | """Parses a single message from buffer and returns it, or None if no 125 | message could be parsed""" 126 | try: 127 | data, end_index = self._decoder.raw_decode(self._buffer) 128 | except ValueError: 129 | if self._buffer: 130 | logging.debug('Invalid message') 131 | else: 132 | logging.debug('Buffer empty') 133 | 134 | return None 135 | 136 | logger.debug('[%s] << %r', self.address, data) 137 | 138 | self._buffer = self._buffer[end_index:] 139 | 140 | ev_data = data.copy() 141 | msg_id = ev_data.pop('msg_id', None) 142 | self.raw_message.send(msg_id, **ev_data) 143 | 144 | if 'type' in data and msg_id == MSG_STATUS: 145 | ev_type = ev_data.pop('type', None) 146 | self.event.send(ev_type, **ev_data) 147 | 148 | return data 149 | 150 | def wait_for_message(self, msg_id=None, timeout=-1, **kwargs): 151 | """Waits for a single message matched by msg_id and kwargs, with 152 | possible timeout (-1 means no timeout), and returns it""" 153 | st = time.time() 154 | while True: 155 | msg = True 156 | 157 | while msg and self._buffer: 158 | msg = self.parse_message() 159 | if not msg: 160 | break 161 | 162 | if msg_id is None or msg['msg_id'] == msg_id and \ 163 | all(p in msg.items() for p in kwargs.items()): 164 | return msg 165 | 166 | if timeout > 0 and time.time() - st > timeout: 167 | raise TimeoutException() 168 | 169 | try: 170 | self._buffer += self._socket.recv(1024) 171 | except socket.timeout: 172 | pass 173 | 174 | def call(self, msg_id, raise_on_error=True, timeout=-1, **kwargs): 175 | """Sends single RPC request, raises RPCError when rval is not 0""" 176 | self.send_message(msg_id, **kwargs) 177 | resp = self.wait_for_message(msg_id, timeout=timeout) 178 | 179 | if resp.get('rval', 0) != 0 and raise_on_error: 180 | raise RPCError(resp) 181 | 182 | return resp 183 | 184 | def run(self): 185 | """Loops forever parsing all incoming messages""" 186 | while True: 187 | self.wait_for_message() 188 | 189 | def config_get(self, param=None): 190 | """Returns dictionary of config values or single config""" 191 | if param: 192 | return self.call(MSG_CONFIG_GET, type=param)['param'] 193 | 194 | data = self.call(MSG_CONFIG_GET_ALL)['param'] 195 | 196 | # Downloaded config is list of single-item dicts 197 | return dict(reduce(lambda o, c: o + c.items(), data, [])) 198 | 199 | def config_set(self, param, value): 200 | """Sets single config value""" 201 | # Wicked. 202 | return self.call(MSG_CONFIG_SET, param=value, type=param) 203 | 204 | def config_describe(self, param): 205 | """Returns config type (`settable` or `readonly`) and possible values 206 | when settable""" 207 | resp = self.call(MSG_CONFIG_GET_ALL, param=param) 208 | type, _, values = resp['param'][0][param].partition(':') 209 | return (type, values.split('#') if values else []) 210 | 211 | def capture(self): 212 | """Captures a photo. Blocks until photo is actually saved""" 213 | self.send_message(MSG_CAPTURE) 214 | return self.wait_for_message(MSG_STATUS, type='photo_taken')['param'] 215 | 216 | def preview_start(self): 217 | """Starts RTSP preview stream available on rtsp://addr/live""" 218 | return self.call(MSG_PREVIEW_START, param='none_force') 219 | 220 | def preview_stop(self): 221 | """Stops live preview""" 222 | return self.call(MSG_PREVIEW_STOP) 223 | 224 | def record_start(self): 225 | """Starts video recording""" 226 | return self.call(MSG_RECORD_START) 227 | 228 | def record_stop(self): 229 | """Stops video recording""" 230 | return self.call(MSG_RECORD_STOP) 231 | 232 | def record_time(self): 233 | """Returns current recording length""" 234 | return self.call(MSG_RECORD_TIME)['param'] 235 | 236 | def battery(self): 237 | """Returns battery status""" 238 | return self.call(MSG_BATTERY) 239 | 240 | def storage_usage(self, type='free'): 241 | """Returns `free` or `total` storage available""" 242 | return self.call(MSG_STORAGE_USAGE, type=type) 243 | 244 | def storage_format(self): 245 | """Formats SD card, use with CAUTION!""" 246 | return self.call(MSG_FORMAT) 247 | 248 | def ls(self, path): 249 | """Returns list of files, adding " -D -S" to path will return more 250 | info""" 251 | return self.call(MSG_LS, param=path) 252 | 253 | def cd(self, path): 254 | """Enters directory""" 255 | return self.call(MSG_CD, param=path) 256 | 257 | def rm(self, path): 258 | """Removes file, supports wildcards""" 259 | return self.call(MSG_RM, param=path) 260 | 261 | def upload(self, path, contents, offset=0): 262 | """Uploads bytes to selected path at offset""" 263 | return self.call( 264 | MSG_UPLOAD_CHUNK, 265 | md5sum=hashlib.md5(contents).hexdigest(), 266 | param=path, 267 | size=len(contents), 268 | offset=offset) 269 | 270 | def mediainfo(self, path): 271 | """Returns information about media file, such as media_type, date, 272 | duration, framerate, size, resolution, ...""" 273 | return self.call(MSG_MEDIAINFO, param=path) 274 | 275 | def zoom_get(self): 276 | """Gets current digital zoom value""" 277 | return int(self.call(MSG_DIGITAL_ZOOM, type='current')['param']) 278 | 279 | def zoom_set(self, value): 280 | """Sets digital zoom""" 281 | return self.call(MSG_DIGITAL_ZOOM_SET, type='fast', param=str(value)) 282 | 283 | # Deprecated 284 | start_preview = preview_start 285 | stop_preview = preview_stop 286 | start_record = record_start 287 | stop_record = record_stop 288 | get_config = config_get 289 | set_config = config_set 290 | describe_config = config_describe 291 | 292 | 293 | if __name__ == '__main__': 294 | logging.basicConfig(level=logging.DEBUG) 295 | 296 | c = AmbaRPCClient() 297 | c.connect() 298 | c.authenticate() 299 | 300 | @c.event.connect_via('vf_start') 301 | def vf_start(*args, **kwargs): 302 | print '*** STARTING ***' 303 | 304 | @c.event.connect_via('vf_stop') 305 | def vf_stop(*args, **kwargs): 306 | print '*** STOPPING ***' 307 | 308 | @c.event.connect_via('video_record_complete') 309 | def complete(type, param): 310 | print 'File saved in', param 311 | 312 | @c.event.connect 313 | def testing(*args, **kwargs): 314 | print 'event:', args, kwargs 315 | 316 | pprint.pprint(c.battery()) 317 | c.run() 318 | --------------------------------------------------------------------------------