prim-ctrl 0.5.0__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: prim-ctrl
3
- Version: 0.5.0
3
+ Version: 0.6.1
4
4
  Summary: Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN.
5
5
  Home-page: https://github.com/lmagyar/prim-ctrl
6
6
  License: Apache-2.0
@@ -27,7 +27,7 @@ Remote control of your phone's [Primitive FTPd Android SFTP server](https://gith
27
27
 
28
28
  Though Primitive FTPd consumes minimal power when it is not used, remote start/stop can be usefull for zeroconf (DNS-SD). Android doesn't reply DSN-SD queries when the screen is off, but Android announces the new service when the Primitive FTPd server starts up. So SFTP clients can/should capture and cache the announcement at Primitive FTPd server startup to connect to a phone through zeroconf even when the screen is off.
29
29
 
30
- But in case of Tailscale, it is a real battery and mobile network data drain when not used, remote start/stop is de facto very useful.
30
+ But in case of Tailscale, it is a real battery and mobile network data drain when not used, local and remote start/stop is de facto very useful.
31
31
 
32
32
  With the help of this script you can sync your phone with eg. your home NAS server whereever your phone is on a WiFi network - or even on cellular. Your phone doesn't have to be on the same LAN to make zeroconf working when you have alternative access through VPN.
33
33
 
@@ -39,9 +39,9 @@ See my other project, https://github.com/lmagyar/prim-batch, for batch execution
39
39
 
40
40
  ## Features
41
41
 
42
- - Remote start/stop of Primitive FTPd Android SFTP server and optionally Tailscale VPN
42
+ - Remote start/stop of Primitive FTPd Android SFTP server and optionally local and remote start/stop of Tailscale VPN
43
43
  - Using VPN on cellular can be refused
44
- - Backup states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
44
+ - Backup sftp server and VPN states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
45
45
 
46
46
  ## Installation
47
47
 
@@ -163,7 +163,7 @@ usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tai
163
163
  Remote control of your phone's Primitive FTPd and optionally Tailscale app statuses via the Automate app, for more details see https://github.com/lmagyar/prim-ctrl
164
164
 
165
165
  Note: you must install Automate app on your phone, download prim-ctrl flow into it, and configure your Google account in the flow to receive messages (see the project's GitHub page for more details)
166
- Note: optionally if your phone is not accessible on local network but your laptop is part of the Tailscale VPN then Tailscale VPN can be started on the phone
166
+ Note: optionally if your phone is not accessible on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone
167
167
  Note: optionally if your laptop is accessible through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored
168
168
 
169
169
  Output: even when -b option is not used, the script will output 'connected=(local|remote)', what you can use to determine whether to use -a option for the prim-sync script
@@ -5,7 +5,7 @@ Remote control of your phone's [Primitive FTPd Android SFTP server](https://gith
5
5
 
6
6
  Though Primitive FTPd consumes minimal power when it is not used, remote start/stop can be usefull for zeroconf (DNS-SD). Android doesn't reply DSN-SD queries when the screen is off, but Android announces the new service when the Primitive FTPd server starts up. So SFTP clients can/should capture and cache the announcement at Primitive FTPd server startup to connect to a phone through zeroconf even when the screen is off.
7
7
 
8
- But in case of Tailscale, it is a real battery and mobile network data drain when not used, remote start/stop is de facto very useful.
8
+ But in case of Tailscale, it is a real battery and mobile network data drain when not used, local and remote start/stop is de facto very useful.
9
9
 
10
10
  With the help of this script you can sync your phone with eg. your home NAS server whereever your phone is on a WiFi network - or even on cellular. Your phone doesn't have to be on the same LAN to make zeroconf working when you have alternative access through VPN.
11
11
 
@@ -17,9 +17,9 @@ See my other project, https://github.com/lmagyar/prim-batch, for batch execution
17
17
 
18
18
  ## Features
19
19
 
20
- - Remote start/stop of Primitive FTPd Android SFTP server and optionally Tailscale VPN
20
+ - Remote start/stop of Primitive FTPd Android SFTP server and optionally local and remote start/stop of Tailscale VPN
21
21
  - Using VPN on cellular can be refused
22
- - Backup states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
22
+ - Backup sftp server and VPN states before starting them, restore when stopping (ie. they won't be stopped if they were running before the script were asked to start them)
23
23
 
24
24
  ## Installation
25
25
 
@@ -141,7 +141,7 @@ usage: prim-ctrl Automate [-h] [-i {test,start,stop}] [-t] [-s] [--debug] [--tai
141
141
  Remote control of your phone's Primitive FTPd and optionally Tailscale app statuses via the Automate app, for more details see https://github.com/lmagyar/prim-ctrl
142
142
 
143
143
  Note: you must install Automate app on your phone, download prim-ctrl flow into it, and configure your Google account in the flow to receive messages (see the project's GitHub page for more details)
144
- Note: optionally if your phone is not accessible on local network but your laptop is part of the Tailscale VPN then Tailscale VPN can be started on the phone
144
+ Note: optionally if your phone is not accessible on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone
145
145
  Note: optionally if your laptop is accessible through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored
146
146
 
147
147
  Output: even when -b option is not used, the script will output 'connected=(local|remote)', what you can use to determine whether to use -a option for the prim-sync script
@@ -1,6 +1,7 @@
1
1
 
2
2
  import argparse
3
3
  import asyncio
4
+ import json
4
5
  import logging
5
6
  import os
6
7
  import platform
@@ -61,6 +62,15 @@ class Logger(logging.Logger):
61
62
  if self.level == logging.NOTSET:
62
63
  self.setLevel(logging.WARNING if silent else logging.INFO)
63
64
 
65
+ def exception_or_error(self, e: Exception, args):
66
+ if not args or args.debug:
67
+ logger.exception(e)
68
+ else:
69
+ if hasattr(e, '__notes__'):
70
+ logger.error("%s: %s", LazyStr(repr, e), LazyStr(", ".join, e.__notes__))
71
+ else:
72
+ logger.error(LazyStr(repr, e))
73
+
64
74
  def error(self, msg, *args, **kwargs):
65
75
  self.exitcode = 1
66
76
  super().error(msg, *args, **kwargs)
@@ -114,6 +124,26 @@ async def async_ping(host, packets: int = 1, timeout: float = 1):
114
124
  _stdout, _stderr = await proc.communicate()
115
125
  return proc.returncode == 0
116
126
 
127
+ async def async_tailscale(args: list[str]):
128
+ command = ['tailscale']
129
+ command.extend(args)
130
+ creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
131
+ proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=creationflags)
132
+ stdout, _stderr = await proc.communicate()
133
+ return proc.returncode == 0, stdout
134
+
135
+ async def async_tailscale_up():
136
+ return (await async_tailscale(['up']))[0]
137
+
138
+ async def async_tailscale_down():
139
+ return (await async_tailscale(['down']))[0]
140
+
141
+ async def async_tailscale_is_online():
142
+ success, stdout = await async_tailscale(['status', '--json', '--peers=false', '--self=true'])
143
+ if success:
144
+ status = json.loads(stdout)
145
+ return success and status['BackendState'] == 'Running' and status['Self']['Online']
146
+
117
147
  ########
118
148
 
119
149
  class Secrets:
@@ -150,8 +180,11 @@ class Pingable:
150
180
  logger.debug("Waiting for %s to be %s (timeout is %ds)", LazyStr(self.get_class_name), LazyStr(Pingable.get_state_name, available), int(timeout))
151
181
  async with asyncio.timeout(timeout):
152
182
  while await self.ping(available) != available:
153
- if not available:
154
- await asyncio.sleep(1)
183
+ await self._sleep_while_wait(available)
184
+
185
+ async def _sleep_while_wait(self, available: bool):
186
+ if not available:
187
+ await asyncio.sleep(1)
155
188
 
156
189
  class Manager:
157
190
  @abstractmethod
@@ -258,10 +291,9 @@ class StateSerializer:
258
291
  e.add_note("Missing '=' in state")
259
292
  raise
260
293
 
261
- class State:
294
+ class PhoneState:
262
295
  WIFI = 'wifi'
263
296
  PFTPD = 'pftpd'
264
- NAMES = { WIFI: "Wi-Fi", PFTPD: "pFTPd"}
265
297
 
266
298
  @abstractmethod
267
299
  async def get(self, repeat: float, timeout: float) -> dict:
@@ -374,7 +406,7 @@ class AutomateTailscaleManager(Manager):
374
406
  async def stop(self):
375
407
  await self.automate.send_message('stop-tailscale')
376
408
 
377
- class AutomateState(State):
409
+ class AutomatePhoneState(PhoneState):
378
410
  VARIABLE_STATE = 'state'
379
411
 
380
412
  def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, external_url: str):
@@ -384,7 +416,7 @@ class AutomateState(State):
384
416
  self.external_url = external_url
385
417
 
386
418
  async def get(self, repeat: float, timeout: float):
387
- logger.info("Getting state...")
419
+ logger.info("Getting Phone state...")
388
420
  # first test funnel + webhooks availability, to not wait for a reply if local tailscale or funnel is down
389
421
  # though it will be routed locally, it will not go out to Tailscale's TCP forwarder servers, so the route is different from what Automate will see
390
422
  logger.debug("Testing funnel with pinging local webhook (timeout is %ds)", int(timeout))
@@ -395,23 +427,23 @@ class AutomateState(State):
395
427
  except:
396
428
  raise RuntimeError(f"Local Tailscale is down or local Funnel is not configured properly for {self.external_url}")
397
429
  # get state
398
- logger.debug("Getting state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
399
- self.webhooks.subscribe_variable(AutomateState.VARIABLE_STATE)
430
+ logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
431
+ self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
400
432
  try:
401
433
  async with asyncio.timeout(timeout):
402
434
  while True:
403
435
  try:
404
- await self.automate.send_message(f'get-state;{self.external_url}{Webhooks.get_variable_path(AutomateState.VARIABLE_STATE)}')
405
- state = await self.webhooks.get_variable(AutomateState.VARIABLE_STATE, min(repeat, timeout))
436
+ await self.automate.send_message(f'get-state;{self.external_url}{Webhooks.get_variable_path(AutomatePhoneState.VARIABLE_STATE)}')
437
+ state = await self.webhooks.get_variable(AutomatePhoneState.VARIABLE_STATE, min(repeat, timeout))
406
438
  break
407
439
  except TimeoutError:
408
440
  pass
409
441
  except TimeoutError as e:
410
- e.add_note(f"Can't get value of {AutomateState.VARIABLE_STATE} for {timeout} seconds - please check on your phone in the Automate app, that the prim-ctrl flow is running")
442
+ e.add_note(f"Can't get value of {AutomatePhoneState.VARIABLE_STATE} for {timeout} seconds - please check on your phone in the Automate app, that the prim-ctrl flow is running")
411
443
  raise
412
444
  finally:
413
- self.webhooks.unsubscribe_variable(AutomateState.VARIABLE_STATE)
414
- logger.info(" state is %s", state)
445
+ self.webhooks.unsubscribe_variable(AutomatePhoneState.VARIABLE_STATE)
446
+ logger.info("Phone state is %s", state)
415
447
  return StateSerializer.loads(state)
416
448
 
417
449
  ########
@@ -567,8 +599,8 @@ class ZeroconfService(Manageable):
567
599
  ########
568
600
 
569
601
  class Phone:
570
- def __init__(self, local_sftp: ZeroconfService, vpn: Device | None, remote_sftp: Service | None, state: State | None):
571
- self.local_sftp = local_sftp
602
+ def __init__(self, zeroconf_sftp: ZeroconfService, vpn: Device | None, remote_sftp: Service | None, state: PhoneState | None):
603
+ self.zeroconf_sftp = zeroconf_sftp
572
604
  self.vpn = vpn
573
605
  self.remote_sftp = remote_sftp
574
606
  self.state = state
@@ -588,28 +620,56 @@ class pFTPdServiceListener(ServiceListener):
588
620
  def del_service(self, service_name: str):
589
621
  pass
590
622
 
591
- class pFTPd(Service):
592
- pass
623
+ class RemotepFTPd(Service):
624
+ def __init__(self, host: str, port: int, manager: Manager):
625
+ super().__init__(host, port, manager)
626
+ self.__qualname__ = "pFTPd"
593
627
 
594
- class pFTPdZeroconf(ZeroconfService):
595
- def __init__(self, *args, **kwargs):
596
- super().__init__(*args, **kwargs)
597
- self.__qualname__ = pFTPd.__qualname__
628
+ class ZeroconfpFTPd(ZeroconfService):
629
+ def __init__(self, service_name: str, service_cache: ServiceCache, service_resolver: ServiceResolver, manager: Manager):
630
+ super().__init__(service_name, service_cache, service_resolver, manager)
631
+ self.__qualname__ = "pFTPd"
598
632
 
599
- class Tailscale(Device):
633
+ class RemoteTailscale(Device):
600
634
  def __init__(self, tailnet: str, machine_name: str, manager: Manager):
601
635
  super().__init__(f'{machine_name}.{tailnet}', manager)
602
636
  self.tailnet = tailnet
637
+ self.__qualname__ = "Remote Tailscale"
603
638
 
604
639
  class Funnel:
605
640
  LOCAL_HOST = '127.0.0.1'
606
641
 
607
- def __init__(self, tailscale: Tailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
642
+ def __init__(self, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
608
643
  self.local_port = local_port
609
644
  self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
610
645
 
611
646
  ########
612
647
 
648
+ class Local:
649
+ def __init__(self, vpn: Manageable | None):
650
+ self.vpn = vpn
651
+
652
+ class LocalTailscaleManager(Manager):
653
+ async def start(self):
654
+ await async_tailscale_up()
655
+
656
+ async def stop(self):
657
+ await async_tailscale_down()
658
+
659
+ class LocalTailscale(Manageable):
660
+ def __init__(self):
661
+ super().__init__(LocalTailscaleManager())
662
+ self.__qualname__ = "Local Tailscale"
663
+
664
+ async def ping(self, availability_hint: bool | None = None):
665
+ logger.debug("Pinging %s", LazyStr(self.get_class_name))
666
+ return await async_tailscale_is_online()
667
+
668
+ async def _sleep_while_wait(self, available: bool):
669
+ await asyncio.sleep(0.250)
670
+
671
+ ########
672
+
613
673
  class WideHelpFormatter(argparse.RawTextHelpFormatter):
614
674
  def __init__(self, prog: str, indent_increment: int = 2, max_help_position: int = 35, width: int | None = None) -> None:
615
675
  super().__init__(prog, indent_increment, max_help_position, width)
@@ -623,11 +683,12 @@ async def gather_with_taskgroup(*coros):
623
683
  raise eg.exceptions[0] from (None if len(eg.exceptions) == 1 else eg)
624
684
 
625
685
  class Control:
626
- WIFI = 'wifi'
627
- VPN = 'vpn'
628
- SFTP = 'sftp'
686
+ PHONE_WIFI = 'remote-wifi'
687
+ LOCAL_VPN = 'local-vpn'
688
+ PHONE_VPN = 'remote-vpn'
689
+ PHONE_SFTP = 'remote-sftp'
629
690
  CONNECTED = 'connected'
630
- LOCAL = 'local'
691
+ ZEROCONF = 'local'
631
692
  REMOTE = 'remote'
632
693
 
633
694
  @staticmethod
@@ -668,51 +729,74 @@ class Control:
668
729
  if args.restore_state and args.intent != 'stop':
669
730
  raise ValueError("The --restore-state option can be enabled only for the stop intent")
670
731
 
671
- async def execute(self, args, phone: Phone):
672
- async def _stop(restore_state: dict | None):
673
- if phone.vpn and phone.remote_sftp and await phone.vpn.test():
674
- if (restore_state is None or not restore_state.get(Control.SFTP, False)) and await phone.remote_sftp.test():
675
- await phone.remote_sftp.stop(10, 30)
676
- if restore_state is None or not restore_state.get(Control.VPN, False):
677
- await phone.vpn.stop(10, 60)
732
+ async def execute(self, args, local: Local, phone: Phone):
733
+ async def _stop(restore_state: dict | None, stop_only_started: bool = False):
734
+ if local.vpn and phone.vpn and phone.remote_sftp and await local.vpn.test() and await phone.vpn.test():
735
+ if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)) and await phone.remote_sftp.test():
736
+ try:
737
+ await phone.remote_sftp.stop(10, 30)
738
+ except Exception as e:
739
+ logger.exception_or_error(e, args)
740
+ if restore_state is None or not restore_state.get(Control.PHONE_VPN, stop_only_started):
741
+ try:
742
+ await phone.vpn.stop(10, 60)
743
+ except Exception as e:
744
+ logger.exception_or_error(e, args)
678
745
  else:
679
- if (restore_state is None or not restore_state.get(Control.SFTP, False)):
680
- await phone.local_sftp.stop(10, 30)
746
+ if (restore_state is None or not restore_state.get(Control.PHONE_SFTP, stop_only_started)):
747
+ try:
748
+ await phone.zeroconf_sftp.stop(10, 30)
749
+ except Exception as e:
750
+ logger.exception_or_error(e, args)
751
+ if local.vpn:
752
+ if restore_state is None or not restore_state.get(Control.LOCAL_VPN, stop_only_started):
753
+ try:
754
+ await local.vpn.stop(10, 30)
755
+ except Exception as e:
756
+ logger.exception_or_error(e, args)
681
757
  match args.intent:
682
758
  case 'test':
683
- if phone.vpn and phone.remote_sftp and phone.state:
684
- phone_state, _vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
685
- for k, v in phone_state.items():
686
- logger.info("%s is %s", LazyStr(lambda: State.NAMES[k]), LazyStr(StateSerializer.dump_value, v))
687
- elif phone.vpn and phone.remote_sftp and await phone.vpn.test():
759
+ if local.vpn and phone.vpn and phone.remote_sftp and phone.state:
760
+ if await local.vpn.test():
761
+ phone_state, _vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
762
+ elif local.vpn and phone.vpn and phone.remote_sftp and await local.vpn.test() and await phone.vpn.test():
688
763
  await phone.remote_sftp.test()
689
764
  else:
690
- await phone.local_sftp.test()
765
+ await phone.zeroconf_sftp.test()
691
766
  case 'start':
692
- if phone.vpn and phone.remote_sftp:
767
+ if local.vpn and phone.vpn and phone.remote_sftp:
693
768
  state = dict()
694
- local_accessible = False
695
- remote_accessible = False
696
- # gather state info
697
- if phone.state:
698
- phone_state, vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
699
- state[Control.WIFI] = phone_state[State.WIFI]
700
- state[Control.VPN] = vpn_state
701
- state[Control.SFTP] = phone_state[State.PFTPD]
702
- if not state[Control.WIFI] and not args.accept_cellular:
703
- raise RuntimeError(f"Phone is not on Wi-Fi network")
704
- else:
705
- state[Control.VPN] = await phone.vpn.test()
706
- if state[Control.VPN]:
707
- state[Control.SFTP] = remote_accessible = await phone.remote_sftp.test()
708
- # start changing state
709
769
  try:
770
+ # gather local state info
771
+ local_vpn_state = await local.vpn.test()
772
+ state[Control.LOCAL_VPN] = local_vpn_state
773
+
774
+ # start changing local state - we need a local vpn to be able to access the state of the remote vpn and optionally the phone
775
+ await local.vpn.start(10, 30)
776
+
777
+ zeroconf_accessible = False
778
+ remote_accessible = False
779
+
780
+ # gather phone state info
710
781
  if phone.state:
711
- if not state[Control.SFTP]:
712
- if not state[Control.VPN]:
713
- if state[Control.WIFI]:
782
+ phone_state, phone_vpn_state = await gather_with_taskgroup(phone.state.get(10, 30), phone.vpn.test())
783
+ state[Control.PHONE_WIFI] = phone_state[PhoneState.WIFI]
784
+ state[Control.PHONE_VPN] = phone_vpn_state
785
+ state[Control.PHONE_SFTP] = phone_state[PhoneState.PFTPD]
786
+ if not state[Control.PHONE_WIFI] and not args.accept_cellular:
787
+ raise RuntimeError(f"Phone is not on Wi-Fi network")
788
+ else:
789
+ state[Control.PHONE_VPN] = await phone.vpn.test()
790
+ if state[Control.PHONE_VPN]:
791
+ state[Control.PHONE_SFTP] = remote_accessible = await phone.remote_sftp.test()
792
+
793
+ # start changing phone state
794
+ if phone.state:
795
+ if not state[Control.PHONE_SFTP]:
796
+ if not state[Control.PHONE_VPN]:
797
+ if state[Control.PHONE_WIFI]:
714
798
  try:
715
- local_accessible = await phone.local_sftp.start(10, 30)
799
+ zeroconf_accessible = await phone.zeroconf_sftp.start(10, 30)
716
800
  except TimeoutError:
717
801
  await phone.vpn.start(10, 60)
718
802
  remote_accessible = await phone.remote_sftp.test()
@@ -721,43 +805,45 @@ class Control:
721
805
  remote_accessible = await phone.remote_sftp.start(10, 30)
722
806
  else:
723
807
  remote_accessible = await phone.remote_sftp.start(10, 30)
724
- if state[Control.WIFI]:
725
- local_accessible = await phone.local_sftp.test()
808
+ if state[Control.PHONE_WIFI]:
809
+ zeroconf_accessible = await phone.zeroconf_sftp.test()
726
810
  else:
727
- if not state[Control.VPN]:
728
- if not state[Control.WIFI] or not (local_accessible := await phone.local_sftp.test()):
811
+ if not state[Control.PHONE_VPN]:
812
+ if not state[Control.PHONE_WIFI] or not (zeroconf_accessible := await phone.zeroconf_sftp.test()):
729
813
  await phone.vpn.start(10, 60)
730
814
  remote_accessible = await phone.remote_sftp.test()
731
815
  else:
732
- local_accessible, remote_accessible = await gather_with_taskgroup(phone.local_sftp.test(), phone.remote_sftp.test())
816
+ zeroconf_accessible, remote_accessible = await gather_with_taskgroup(phone.zeroconf_sftp.test(), phone.remote_sftp.test())
733
817
  else:
734
- if not state[Control.VPN]:
735
- if not (local_accessible := await phone.local_sftp.test()):
818
+ if not state[Control.PHONE_VPN]:
819
+ if not (zeroconf_accessible := await phone.zeroconf_sftp.test()):
736
820
  try:
737
- local_accessible = await phone.local_sftp.start(10, 30)
821
+ zeroconf_accessible = await phone.zeroconf_sftp.start(10, 30)
738
822
  except TimeoutError:
739
823
  await phone.vpn.start(10, 60)
740
824
  remote_accessible = await phone.remote_sftp.test()
741
825
  else:
742
- if not state[Control.SFTP]:
826
+ if not state[Control.PHONE_SFTP]:
743
827
  remote_accessible = await phone.remote_sftp.start(10, 30)
744
- local_accessible = await phone.local_sftp.test()
745
- if not local_accessible and not remote_accessible:
828
+ zeroconf_accessible = await phone.zeroconf_sftp.test()
829
+ if not zeroconf_accessible and not remote_accessible:
746
830
  raise RuntimeError(f"Even when {phone.vpn.get_class_name()} and {phone.remote_sftp.get_class_name()} is started, {phone.remote_sftp.get_class_name()} is still not accessible")
831
+
832
+ # print out result on stdout
833
+ if not args.backup_state:
834
+ state = dict()
835
+ state[Control.CONNECTED] = Control.ZEROCONF if zeroconf_accessible else Control.REMOTE
836
+ print(StateSerializer.dumps(state))
747
837
  except:
748
- await _stop(state)
838
+ await _stop(state, stop_only_started = True)
749
839
  raise
750
- if not args.backup_state:
751
- state = dict()
752
- state[Control.CONNECTED] = Control.LOCAL if local_accessible else Control.REMOTE
753
- print(StateSerializer.dumps(state))
754
840
  else:
755
- if not await phone.local_sftp.test():
756
- try:
757
- await phone.local_sftp.start(10, 30)
758
- except:
759
- await _stop(None)
760
- raise
841
+ try:
842
+ if not await phone.zeroconf_sftp.test():
843
+ await phone.zeroconf_sftp.start(10, 30)
844
+ except:
845
+ await _stop(None)
846
+ raise
761
847
  case 'stop':
762
848
  await _stop(StateSerializer.loads(args.restore_state) if args.restore_state else None)
763
849
 
@@ -767,7 +853,7 @@ class AutomateControl(Control):
767
853
  parser = subparsers.add_parser('Automate', aliases=['a'],
768
854
  description="Remote control of your phone's Primitive FTPd and optionally Tailscale app statuses via the Automate app, for more details see https://github.com/lmagyar/prim-ctrl\n\n"
769
855
  "Note: you must install Automate app on your phone, download prim-ctrl flow into it, and configure your Google account in the flow to receive messages (see the project's GitHub page for more details)\n"
770
- "Note: optionally if your phone is not accessible on local network but your laptop is part of the Tailscale VPN then Tailscale VPN can be started on the phone\n"
856
+ "Note: optionally if your phone is not accessible on local network but your laptop and phone is part of the Tailscale VPN then Tailscale VPN can be started on the phone\n"
771
857
  "Note: optionally if your laptop is accessible through Tailscale Funnel then VPN on cellular can be refused and app statuses on the phone can be backed up and restored\n\n"
772
858
  "Output: even when -b option is not used, the script will output 'connected=(local|remote)', what you can use to determine whether to use -a option for the prim-sync script",
773
859
  formatter_class=WideHelpFormatter)
@@ -830,15 +916,17 @@ class AutomateControl(Control):
830
916
  await service_browser.add_service_listener(service_listener)
831
917
 
832
918
  automate = Automate(Secrets(), session, args.automate_account, args.automate_device, args.automate_tokenfile)
833
- local_pftpd = pFTPdZeroconf(args.server_name, service_cache, service_resolver, AutomatepFTPdManager(automate))
834
- tailscale = Tailscale(args.tailscale[0], args.tailscale[1], AutomateTailscaleManager(automate)) if args.tailscale else None
835
- remote_pftpd = pFTPd(tailscale.host, int(args.tailscale[2]), local_pftpd.manager) if tailscale else None
836
- funnel = Funnel(tailscale, args.funnel[0], int(args.funnel[1]), args.funnel[2], int(args.funnel[3])) if tailscale and args.funnel else None
919
+ local_tailscale = LocalTailscale() if args.tailscale else None
920
+ remote_tailscale = RemoteTailscale(args.tailscale[0], args.tailscale[1], AutomateTailscaleManager(automate)) if args.tailscale else None
921
+ zeroconf_pftpd = ZeroconfpFTPd(args.server_name, service_cache, service_resolver, AutomatepFTPdManager(automate))
922
+ remote_pftpd = RemotepFTPd(remote_tailscale.host, int(args.tailscale[2]), zeroconf_pftpd.manager) if remote_tailscale else None
923
+ funnel = Funnel(remote_tailscale, args.funnel[0], int(args.funnel[1]), args.funnel[2], int(args.funnel[3])) if remote_tailscale and args.funnel else None
837
924
 
838
925
  async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
839
- state = AutomateState(session, webhooks, automate, funnel.external_url) if funnel and webhooks else None
840
- phone = Phone(local_pftpd, tailscale, remote_pftpd, state)
841
- await self.execute(args, phone)
926
+ local = Local(local_tailscale)
927
+ automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel.external_url) if funnel and webhooks else None
928
+ phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
929
+ await self.execute(args, local, phone)
842
930
 
843
931
  async def main():
844
932
  args = None
@@ -855,13 +943,7 @@ async def main():
855
943
  await args.ctor().run(args)
856
944
 
857
945
  except Exception as e:
858
- if not args or args.debug:
859
- logger.exception(e)
860
- else:
861
- if hasattr(e, '__notes__'):
862
- logger.error("%s: %s", LazyStr(repr, e), LazyStr(", ".join, e.__notes__))
863
- else:
864
- logger.error(LazyStr(repr, e))
946
+ logger.exception_or_error(e, args)
865
947
 
866
948
  return logger.exitcode
867
949
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "prim-ctrl"
3
- version = "0.5.0"
3
+ version = "0.6.1"
4
4
  description = "Primitive Ctrl - Remote control of your phone's Primitive FTPd Android SFTP server and optionally Tailscale VPN."
5
5
  license = "Apache-2.0"
6
6
  authors = ["Laszlo Magyar <lmagyar1973@gmail.com>"]
File without changes