-
Notifications
You must be signed in to change notification settings - Fork 7
/
Copy pathinsta360-remote
executable file
·412 lines (369 loc) · 14.3 KB
/
insta360-remote
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
#!/usr/bin/env python3
"""
Remote control program for the Insta360 action camera, developed
an tested on the Insta360 ONE RS, firmware revision v2.0.8.
It runs in a text terminal using the Python curses library;
developed and tested with Python 3.9 and curses 2.2.
It requires the insta360.py Python module.
"""
import insta360
import curses
import logging
import sys
import time
__author__ = "Niccolo Rigacci"
__copyright__ = "Copyright 2023 Niccolo Rigacci <[email protected]>"
__license__ = "GPLv3-or-later"
__email__ = "[email protected]"
__version__ = "0.1.0"
# Windows height, with borders.
MAIN_WIN_HEIGHT = 13
STATUS_WIN_HEIGHT = 4
MIN_ROWS = 20
MIN_COLS = 46
KEY_ESC = 27
MAIN_MENU = """
1) Start Capture
2) Stop Capture
3) Take Photo
4) Video Resolution
5) Field of View
6) Gamma & White
7) Presets
Q) Quit
"""
VIDEO_RESOLUTION_MENU = """
1) 4K 60
2) 4K 30
3) 2.7K 60
4) 2.7K 30
5) 1080 60
6) 1080 30
9) Back
"""
FOV_MENU = """
1) ULTRAWIDE 17.0 mm
2) WIDE 21.0 mm
3) LINEAR 22.2 mm
4) NARROW 28.9 mm
5) Tele 50.0 mm
9) Back
"""
VIDEO_OPT_MENU = """
1) Gamma VIVID
2) Gamma STANDARD
3) Gamma LOG
4) White Balance
9) Back
"""
VIDEO_WHITE_BALANCE = """
1) AUTO
2) 2700K Tungsten
3) 4000K Fluorescent
4) 5000K Daylight
5) 6500K Cloudy
6) 7500K Shade
9) Back
"""
PRESETS_MENU = """
1) 1080@60, 17.0mm, STANDARD, Auto WB
2) Custom Set #2
3) Custom Set #3
9) Back
"""
logger = None
win_status = None
capture_state = None
battery_level = None
free_space = None
gamma_mode = None
white_balance_value = None
record_resolution = None
focal_length_value = 0
fov_type = None
class CursesLogHandler(logging.Handler):
""" Logging handler which outputs to a curses window """
def __init__(self, screen):
logging.Handler.__init__(self)
self.screen = screen
def emit(self, record):
try:
msg = self.format(record)
screen = self.screen
fs = "\n%s"
screen.addstr(fs % msg)
screen.refresh()
except (KeyboardInterrupt, SystemExit):
raise
except:
raise
def boxed_window_mv(outer, inner, y, x, h, w, title):
""" Move and resize a window with border at (x, y) """
outer.resize(h, w)
outer.mvwin(y, x)
outer.clear()
outer.box()
outer.addstr(0, 2, ' %s ' % (title,))
outer.refresh()
inner.resize(h - 2, w - 2)
inner.mvderwin(1, 1)
inner.clear()
inner.refresh()
def redraw_screen(screen, box_menu, win_menu, box_status, win_status, box_logging, win_logging):
""" Calculate the sizes of the windows (menu, status and log) and refresh them """
rows, cols = screen.getmaxyx()
if rows < MIN_ROWS or cols < MIN_COLS:
rows = max(MIN_ROWS, rows)
cols = max(MIN_COLS, cols)
screen.resize(rows, cols)
rows, cols = screen.getmaxyx()
curses.resizeterm(rows, cols)
screen.clear()
screen.refresh()
boxed_window_mv(box_menu, win_menu, 0, 0, MAIN_WIN_HEIGHT, cols, 'Insta360 Remote')
boxed_window_mv(box_status, win_status, MAIN_WIN_HEIGHT, 0, STATUS_WIN_HEIGHT, cols, 'Rec, Battery, SD Free, Resolution, FOV')
boxed_window_mv(box_logging, win_logging, MAIN_WIN_HEIGHT + STATUS_WIN_HEIGHT, 0, rows - MAIN_WIN_HEIGHT - STATUS_WIN_HEIGHT, cols, 'Logging')
def pretty_bytes(bytes_int):
""" Format a bytes number into a pretty string """
if bytes_int is None:
return None
else:
if bytes_int > (1024 ** 3):
return '%.1f Gb' % (float(bytes_int) / (1024 ** 3),)
elif bytes_int > (1024 ** 2):
return '%.0f Mb' % (float(bytes_int) / (1024 ** 2),)
else:
return '%.0f kb' % (float(bytes_int) / (1024),)
def safe_addstr(string, win, extra_x=None, extra_y=None):
""" Trim the string to window's acceptable sizes before calling addstr() """
rows, cols = win.getmaxyx()
if extra_x is not None:
cols -= extra_x
if extra_y is not None:
rows -= extra_y
trimmed = []
for row in string.splitlines():
# Leave an empty column to accomodate the newline.
trimmed.append(row[:cols-1])
try:
win.addstr('\n'.join(trimmed[:rows]))
except:
pass
def handle_message(msg):
""" Handle the messages received from the Insta360 updating the status bar """
global logger, win_status
global capture_state, battery_level, free_space, gamma_mode, white_balance_value, record_resolution, focal_length_value, fov_type
# The Protobuf message is passed as a Python dictionary;
# beware of indexes which are camelCase without underscores.
logger.info('Handling message: %s' % (msg,))
if msg['response_code'] == insta360.camera.CAMERA_NOTIFICATION_CURRENT_CAPTURE_STATUS:
try:
capture_state = msg['state']
except:
pass
elif msg['response_code'] == insta360.camera.RESPONSE_CODE_OK:
if msg['message_code'] == insta360.camera.PHONE_COMMAND_GET_OPTIONS:
try:
battery_level = int(msg['value']['batteryStatus']['batteryLevel'])
except:
pass
try:
free_space = int(msg['value']['storageState']['freeSpace'])
except:
pass
elif msg['message_code'] == insta360.camera.PHONE_COMMAND_GET_PHOTOGRAPHY_OPTIONS:
try:
gamma_mode = msg['value']['gammaMode']
except:
pass
try:
white_balance_value = msg['value']['whiteBalanceValue']
except:
pass
try:
record_resolution = msg['value']['recordResolution'][4:]
except:
pass
try:
focal_length_value = float(msg['value']['focalLengthValue'])
except:
pass
try:
fov_type = msg['value']['fovType'][4:]
except:
pass
elif msg['message_code'] == insta360.camera.PHONE_COMMAND_GET_CURRENT_CAPTURE_STATUS:
try:
capture_state = msg['status']['state']
capture_time = msg['status']['captureTime']
except:
pass
status_log = '%s, %s%%, %s, %s, %.1fmm %s, Gamma: %s, WB: %s' % (capture_state, battery_level, pretty_bytes(free_space), record_resolution, focal_length_value, fov_type, gamma_mode, white_balance_value)
logger.info('Status updated: %s' % (status_log,))
win_status.clear()
if capture_state in [None, 'NOT_CAPTURE', 'SINGLE_SHOOTING', 'HDR_SHOOTING', 'SELFIE_RECORDING_CAPTURE']:
win_status.addstr(' S ', curses.color_pair(curses.COLOR_WHITE))
else:
win_status.addstr(' R ', curses.color_pair(curses.COLOR_RED))
status = ' %s%%, %s, %s, %.1fmm %s\nGamma: %s, WB: %s' % (battery_level, pretty_bytes(free_space), record_resolution, focal_length_value, fov_type, gamma_mode, white_balance_value)
safe_addstr(status, win_status, extra_x=3)
win_status.refresh()
def main(screen):
global logger, win_status
# Initialize curses.
curses.initscr()
curses.curs_set(0)
screen.keypad(True)
# Prepare red and white colors for recording status.
curses.start_color()
curses.init_pair(curses.COLOR_RED, curses.COLOR_BLACK, curses.COLOR_RED)
curses.init_pair(curses.COLOR_WHITE, curses.COLOR_BLACK, curses.COLOR_WHITE)
# Initialize boxes.
rows, cols = screen.getmaxyx()
box_menu = curses.newwin(MAIN_WIN_HEIGHT, cols, 0, 0)
win_menu = box_menu.derwin(MAIN_WIN_HEIGHT - 2, cols - 2, 1, 1)
box_status = curses.newwin(STATUS_WIN_HEIGHT, cols, MAIN_WIN_HEIGHT, 0)
win_status = box_status.derwin(STATUS_WIN_HEIGHT - 2, cols - 2, 1, 1)
box_logging = curses.newwin(rows - MAIN_WIN_HEIGHT - STATUS_WIN_HEIGHT, cols, MAIN_WIN_HEIGHT + STATUS_WIN_HEIGHT, 0)
win_logging = box_logging.derwin(rows - MAIN_WIN_HEIGHT - STATUS_WIN_HEIGHT - 2, cols - 2, 1, 1)
redraw_screen(screen, box_menu, win_menu, box_status, win_status, box_logging, win_logging)
win_logging.scrollok(True)
win_logging.leaveok(True)
win_logging.clearok(True)
# Initialize logging handlers.
curses_log_handler = CursesLogHandler(win_logging)
formatter_display = logging.Formatter('%(asctime)-8s|%(levelname)-7s|%(message)-s', '%H:%M:%S')
curses_log_handler.setFormatter(formatter_display)
logger = logging.getLogger('Insta360 Remote')
logger.addHandler(curses_log_handler)
if True:
file_log_handler = logging.FileHandler(filename='remote.log')
formatter_file = logging.Formatter('%(asctime)s %(levelname)-8s %(message)s')
file_log_handler.setFormatter(formatter_file)
logger.addHandler(file_log_handler)
logger.setLevel(logging.INFO)
logger.info('Starting %s' % (sys.argv[0],))
cam = insta360.camera(host='192.168.42.1', port=6666, logger=logger, callback=handle_message)
cam.Open()
current_menu = MAIN_MENU
win_menu.timeout(6000)
cam.GetCameraInfo()
cam.GetNormalVideoOptions()
cam.GetCaptureCurrentStatus()
# TESTING: Fake status message.
#msg = {
# 'response_code': insta360.camera.RESPONSE_CODE_OK,
# 'message_code': insta360.camera.PHONE_COMMAND_GET_OPTIONS,
# 'value': { 'batteryStatus': { 'batteryLevel': 65 }, 'storageState': { 'freeSpace': 34359738368 } } }
#handle_message(msg)
#msg = {
# 'response_code': insta360.camera.RESPONSE_CODE_OK,
# 'message_code': insta360.camera.PHONE_COMMAND_GET_PHOTOGRAPHY_OPTIONS,
# 'value': { 'gammaMode': 'VIVID', 'whiteBalanceValue': 2800, 'recordResolution': 'RES_1920_1080P30', 'focalLengthValue': 22.2, 'fovType': 'FOV_LINEAR' } }
#handle_message(msg)
while True:
win_menu.clear()
safe_addstr(current_menu, win_menu)
win_menu.refresh()
curses.flushinp()
key = win_menu.getch()
if key == curses.KEY_RESIZE or key == ord('r'):
redraw_screen(screen, box_menu, win_menu, box_status, win_status, box_logging, win_logging)
elif key == -1:
# Timeout waiting key: refresh status.
cam.GetCameraInfo()
cam.GetNormalVideoOptions()
cam.GetCaptureCurrentStatus()
continue
if current_menu == MAIN_MENU:
if key == ord('1'):
cam.StartCapture()
elif key == ord('2'):
cam.StopCapture()
elif key == ord('3'):
cam.TakePicture()
elif key == ord('4'):
current_menu = VIDEO_RESOLUTION_MENU
elif key == ord('5'):
current_menu = FOV_MENU
elif key == ord('6'):
current_menu = VIDEO_OPT_MENU
elif key == ord('7'):
current_menu = PRESETS_MENU
elif key == ord('q') or key == ord('Q'):
break
else:
logger.debug('getkey() returned %s' % (key,))
elif current_menu == VIDEO_RESOLUTION_MENU:
if key == ord('9') or key == KEY_ESC:
current_menu = MAIN_MENU
elif key == ord('1'):
cam.SetNormalVideoOptions(record_resolution='RES_3840_2160P60')
elif key == ord('2'):
cam.SetNormalVideoOptions(record_resolution='RES_3840_2160P30')
elif key == ord('3'):
cam.SetNormalVideoOptions(record_resolution='RES_2720_1530P60')
elif key == ord('4'):
cam.SetNormalVideoOptions(record_resolution='RES_2720_1530P30')
elif key == ord('5'):
cam.SetNormalVideoOptions(record_resolution='RES_1920_1080P60')
elif key == ord('6'):
cam.SetNormalVideoOptions(record_resolution='RES_1920_1080P30')
cam.GetNormalVideoOptions()
elif current_menu == FOV_MENU:
if key == ord('9') or key == KEY_ESC:
current_menu = MAIN_MENU
elif key == ord('1'):
cam.SetNormalVideoOptions(focal_length_value=17.0)
elif key == ord('2'):
cam.SetNormalVideoOptions(focal_length_value=22.0)
elif key == ord('3'):
cam.SetNormalVideoOptions(focal_length_value=22.2)
elif key == ord('4'):
cam.SetNormalVideoOptions(focal_length_value=28.9)
elif key == ord('5'):
cam.SetNormalVideoOptions(focal_length_value=50.0)
cam.GetNormalVideoOptions()
elif current_menu == VIDEO_OPT_MENU:
if key == ord('9') or key == KEY_ESC:
current_menu = MAIN_MENU
elif key == ord('1'):
cam.SetNormalVideoOptions(gamma_mode='VIVID')
elif key == ord('2'):
cam.SetNormalVideoOptions(gamma_mode='STANDARD')
elif key == ord('3'):
cam.SetNormalVideoOptions(gamma_mode='LOG')
elif key == ord('4'):
current_menu = VIDEO_WHITE_BALANCE
cam.GetNormalVideoOptions()
elif current_menu == VIDEO_WHITE_BALANCE:
if key == ord('9') or key == KEY_ESC:
current_menu = VIDEO_OPT_MENU
elif key == ord('1'):
cam.SetNormalVideoOptions(white_balance='WB_AUTO') # white_balance_value = 0
elif key == ord('2'):
cam.SetNormalVideoOptions(white_balance_value=2800) # white_balance = WB_2700K ?
elif key == ord('3'):
cam.SetNormalVideoOptions(white_balance_value=4000) # white_balance = WB_4000K
elif key == ord('4'):
cam.SetNormalVideoOptions(white_balance_value=5000) # white_balance = WB_7500K ?
elif key == ord('5'):
cam.SetNormalVideoOptions(white_balance_value=6500) # white_balance = WB_5000K ?
elif key == ord('6'):
cam.SetNormalVideoOptions(white_balance_value=7500) # white_balance = WB_6500K ?
cam.GetNormalVideoOptions()
elif current_menu == PRESETS_MENU:
if key == ord('9') or key == KEY_ESC:
current_menu = MAIN_MENU
elif key == ord('1'):
cam.SetNormalVideoOptions(
record_resolution='RES_1920_1080P60',
focal_length_value=17.0,
gamma_mode='STANDARD',
white_balance='WB_AUTO'
)
cam.GetNormalVideoOptions()
cam.Close()
curses.endwin()
# Execute the main loop.
curses.wrapper(main)