prim-ctrl 0.6.1__tar.gz → 0.6.2__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.6.1
3
+ Version: 0.6.2
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
@@ -13,9 +13,10 @@ Classifier: License :: OSI Approved :: Apache Software License
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.12
16
- Requires-Dist: aiohttp (>=3.11.0,<4.0.0)
16
+ Requires-Dist: aiohttp (>=3.11.10,<4.0.0)
17
+ Requires-Dist: dnspython (>=2.7.0,<3.0.0)
17
18
  Requires-Dist: platformdirs (>=4.3.6,<5.0.0)
18
- Requires-Dist: zeroconf (>=0.136.0,<0.137.0)
19
+ Requires-Dist: zeroconf (>=0.136.2,<0.137.0)
19
20
  Project-URL: Change Log, https://github.com/lmagyar/prim-ctrl/blob/main/CHANGELOG.md
20
21
  Project-URL: Repository, https://github.com/lmagyar/prim-ctrl
21
22
  Description-Content-Type: text/markdown
@@ -136,6 +137,7 @@ But if the script runs scheduled, we can't be sure whether the phone is on WiFi,
136
137
 
137
138
  Notes:
138
139
  - 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
140
+ - If local Tailscale VPN was disconnected for a longer period (several hours), the public DNS records for Funnel are removed by Tailscale, and after connecting local Tailscale VPN to the tailnet it can take up to 10 minutes for Funnel's public DNS records to show up for your tailnet domain. If the script connects local Tailscale VPN to the tailnet, then it regularly checks and waits up to 10 minutes for the public DNS records to get updated.
139
141
 
140
142
  ### Some example
141
143
 
@@ -114,6 +114,7 @@ But if the script runs scheduled, we can't be sure whether the phone is on WiFi,
114
114
 
115
115
  Notes:
116
116
  - 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
117
+ - If local Tailscale VPN was disconnected for a longer period (several hours), the public DNS records for Funnel are removed by Tailscale, and after connecting local Tailscale VPN to the tailnet it can take up to 10 minutes for Funnel's public DNS records to show up for your tailnet domain. If the script connects local Tailscale VPN to the tailnet, then it regularly checks and waits up to 10 minutes for the public DNS records to get updated.
117
118
 
118
119
  ### Some example
119
120
 
@@ -16,6 +16,8 @@ from pathlib import Path
16
16
  from typing import Dict
17
17
 
18
18
  import aiohttp
19
+ import dns.asyncresolver
20
+ import dns.resolver
19
21
  from aiohttp import ClientTimeout, web
20
22
  from platformdirs import user_cache_dir
21
23
  from zeroconf import Zeroconf, ServiceInfo, ServiceListener as ZeroconfServiceListener
@@ -35,7 +37,7 @@ class LevelFormatter(logging.Formatter):
35
37
  return self.formatters.get(record.levelno, self.default_formatter).format(record)
36
38
 
37
39
  class Logger(logging.Logger):
38
- def __init__(self, name, level=logging.NOTSET):
40
+ def __init__(self, name, level = logging.NOTSET):
39
41
  super().__init__(name, level)
40
42
  self.exitcode = 0
41
43
 
@@ -99,50 +101,43 @@ logger = Logger(Path(sys.argv[0]).name)
99
101
 
100
102
  ########
101
103
 
102
- # based on https://stackoverflow.com/a/55656177/2755656
103
- def sync_ping(host, packets: int = 1, timeout: float = 1):
104
- if platform.system().lower() == 'windows':
105
- command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
106
- # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
107
- result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
108
- return result.returncode == 0 and b'TTL=' in result.stdout
109
- else:
110
- command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
111
- result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
112
- return result.returncode == 0
113
-
114
- async def async_ping(host, packets: int = 1, timeout: float = 1):
115
- if platform.system().lower() == 'windows':
116
- command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
117
- # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
118
- proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
104
+ class Subprocess:
105
+
106
+ # based on https://stackoverflow.com/a/55656177/2755656
107
+ @staticmethod
108
+ def sync_ping(host, packets: int = 1, timeout: float = 1):
109
+ if platform.system().lower() == 'windows':
110
+ command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
111
+ # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
112
+ result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
113
+ return result.returncode == 0 and b'TTL=' in result.stdout
114
+ else:
115
+ command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
116
+ result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
117
+ return result.returncode == 0
118
+
119
+ @staticmethod
120
+ async def async_ping(host, packets: int = 1, timeout: float = 1):
121
+ if platform.system().lower() == 'windows':
122
+ command = ['ping', '-n', str(packets), '-w', str(int(timeout*1000)), host]
123
+ # don't use text=True, the async version will raise ValueError("text must be False"), who knows why
124
+ proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW)
125
+ stdout, _stderr = await proc.communicate()
126
+ return proc.returncode == 0 and b'TTL=' in stdout
127
+ else:
128
+ command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
129
+ proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
130
+ _stdout, _stderr = await proc.communicate()
131
+ return proc.returncode == 0
132
+
133
+ @staticmethod
134
+ async def async_tailscale(args: list[str]):
135
+ command = ['tailscale']
136
+ command.extend(args)
137
+ creationflags = subprocess.CREATE_NO_WINDOW if platform.system().lower() == 'windows' else 0
138
+ proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, creationflags=creationflags)
119
139
  stdout, _stderr = await proc.communicate()
120
- return proc.returncode == 0 and b'TTL=' in stdout
121
- else:
122
- command = ['ping', '-c', str(packets), '-W', str(int(timeout)), host]
123
- proc = await asyncio.create_subprocess_exec(*command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
124
- _stdout, _stderr = await proc.communicate()
125
- return proc.returncode == 0
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']
140
+ return proc.returncode == 0, stdout
146
141
 
147
142
  ########
148
143
 
@@ -265,7 +260,7 @@ class Device(Manageable):
265
260
 
266
261
  async def ping(self, availability_hint: bool | None = None):
267
262
  logger.debug("Pinging %s (%s)", LazyStr(self.get_class_name), self.host)
268
- return await async_ping(self.host, timeout=2)
263
+ return await Subprocess.async_ping(self.host, timeout=2)
269
264
 
270
265
  class StateSerializer:
271
266
  BOOL = {False: Pingable.get_state_name(False), True: Pingable.get_state_name(True)}
@@ -301,153 +296,6 @@ class PhoneState:
301
296
 
302
297
  ########
303
298
 
304
- class Webhooks:
305
- PING_PATH = 'ping'
306
- VARIABLE_PATH = 'variable'
307
-
308
- def __init__(self, host: str, port: int):
309
- self.host = host
310
- self.port = port
311
- self.variables = dict[str, asyncio.Queue[str]]()
312
-
313
- @staticmethod
314
- def get_ping_path():
315
- return f'/{Webhooks.PING_PATH}'
316
-
317
- @staticmethod
318
- def get_variable_path(variable: str):
319
- return f'/{Webhooks.VARIABLE_PATH}/{variable}'
320
-
321
- async def _start(self):
322
- async def _ping(request: web.Request):
323
- return web.Response(text='pong')
324
- async def _receive_variable(request: web.Request):
325
- queue = self.variables.get(request.match_info['name'])
326
- if queue:
327
- with suppress(asyncio.QueueFull):
328
- queue.put_nowait(await request.text())
329
- return web.Response(text='OK')
330
- app = web.Application()
331
- app.add_routes([
332
- web.get(f'/{Webhooks.PING_PATH}', _ping),
333
- web.post(f'/{Webhooks.VARIABLE_PATH}' + r'/{name}', _receive_variable)])
334
- self.runner = web.AppRunner(app)
335
- await self.runner.setup()
336
- self.site = web.TCPSite(self.runner, host=self.host, port=self.port)
337
- await self.site.start()
338
-
339
- async def _stop(self):
340
- await self.runner.cleanup()
341
-
342
- def subscribe_variable(self, variable: str):
343
- if variable not in self.variables:
344
- self.variables[variable] = asyncio.Queue[str](maxsize=16)
345
-
346
- def unsubscribe_variable(self, variable: str):
347
- self.variables.pop(variable)
348
-
349
- async def get_variable(self, variable: str, timeout: float):
350
- queue = self.variables.get(variable)
351
- if not queue:
352
- raise ValueError(f"The {variable} is unknown")
353
- try:
354
- async with asyncio.timeout(timeout):
355
- return await queue.get()
356
- except TimeoutError as e:
357
- e.add_note(f"Can't get value of {variable} for {timeout} seconds")
358
- raise
359
-
360
- def __enter__(self):
361
- raise TypeError("Use async with instead")
362
- def __exit__(self, exc_type, exc_value, exc_tb):
363
- pass
364
- async def __aenter__(self):
365
- await self._start()
366
- return self
367
- async def __aexit__(self, exc_type, exc_value, exc_tb):
368
- await self._stop()
369
-
370
- class Automate:
371
- def __init__(self, secrets: Secrets, session: aiohttp.ClientSession, account: str, device: str, tokenfile: str):
372
- self.session = session
373
- self.account = account
374
- self.device = device
375
- self.secret = secrets.get(tokenfile)
376
-
377
- async def send_message(self, message: str):
378
- data = {
379
- "secret": self.secret,
380
- "to": self.account,
381
- "device": self.device,
382
- "priority": "high",
383
- "payload": f"prim-ctrl;{time.time()};" + message
384
- }
385
- logger.debug("Messaging Automate with: %s", message)
386
- async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
387
- await response.text()
388
-
389
- class AutomatepFTPdManager(Manager):
390
- def __init__(self, automate: Automate):
391
- self.automate = automate
392
-
393
- async def start(self):
394
- await self.automate.send_message('start-pftpd')
395
-
396
- async def stop(self):
397
- await self.automate.send_message('stop-pftpd')
398
-
399
- class AutomateTailscaleManager(Manager):
400
- def __init__(self, automate: Automate):
401
- self.automate = automate
402
-
403
- async def start(self):
404
- await self.automate.send_message('start-tailscale')
405
-
406
- async def stop(self):
407
- await self.automate.send_message('stop-tailscale')
408
-
409
- class AutomatePhoneState(PhoneState):
410
- VARIABLE_STATE = 'state'
411
-
412
- def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, external_url: str):
413
- self.session = session
414
- self.webhooks = webhooks
415
- self.automate = automate
416
- self.external_url = external_url
417
-
418
- async def get(self, repeat: float, timeout: float):
419
- logger.info("Getting Phone state...")
420
- # first test funnel + webhooks availability, to not wait for a reply if local tailscale or funnel is down
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
422
- logger.debug("Testing funnel with pinging local webhook (timeout is %ds)", int(timeout))
423
- try:
424
- async with self.session.get(f'{self.external_url}{Webhooks.get_ping_path()}', timeout=ClientTimeout(total=timeout)) as response:
425
- if await response.text() != 'pong':
426
- raise Exception()
427
- except:
428
- raise RuntimeError(f"Local Tailscale is down or local Funnel is not configured properly for {self.external_url}")
429
- # get 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)
432
- try:
433
- async with asyncio.timeout(timeout):
434
- while True:
435
- try:
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))
438
- break
439
- except TimeoutError:
440
- pass
441
- except TimeoutError as e:
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")
443
- raise
444
- finally:
445
- self.webhooks.unsubscribe_variable(AutomatePhoneState.VARIABLE_STATE)
446
- logger.info("Phone state is %s", state)
447
- return StateSerializer.loads(state)
448
-
449
- ########
450
-
451
299
  class Cache:
452
300
  PRIM_SYNC_APP_NAME = 'prim-sync'
453
301
 
@@ -557,19 +405,19 @@ class ZeroconfService(Manageable):
557
405
 
558
406
  async def ping(self, availability_hint: bool | None = None):
559
407
  async def _connect(connect_timeout: float, resolve_timeout: float):
560
- async def asyncio_open_connection(host: str, port: int, timeout: float):
408
+ async def _asyncio_open_connection(host: str, port: int, timeout: float):
561
409
  logger.debug(" Connecting to %s on port %d (timeout is %ds)", host, port, timeout)
562
410
  async with asyncio.timeout(timeout):
563
411
  return await asyncio.open_connection(host, port)
564
- async def service_resolver_get(service_name: str, timeout: float):
412
+ async def _service_resolver_get(service_name: str, timeout: float):
565
413
  logger.debug(" Resolving %s (timeout is %ds)", service_name, timeout)
566
414
  return await self.service_resolver.get(service_name, timeout)
567
415
  if self.host and self.port:
568
- return await asyncio_open_connection(self.host, self.port, connect_timeout)
416
+ return await _asyncio_open_connection(self.host, self.port, connect_timeout)
569
417
  host, port = self.service_cache.get(self.service_name)
570
418
  if host and port:
571
419
  try:
572
- reader_writer = await asyncio_open_connection(host, port, connect_timeout)
420
+ reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
573
421
  self.host = host
574
422
  self.port = port
575
423
  return reader_writer
@@ -578,8 +426,8 @@ class ZeroconfService(Manageable):
578
426
  pass
579
427
  else:
580
428
  raise
581
- host, port = await service_resolver_get(self.service_name, resolve_timeout)
582
- reader_writer = await asyncio_open_connection(host, port, connect_timeout)
429
+ host, port = await _service_resolver_get(self.service_name, resolve_timeout)
430
+ reader_writer = await _asyncio_open_connection(host, port, connect_timeout)
583
431
  self.service_cache.set(self.service_name, host, port)
584
432
  self.host = host
585
433
  self.port = port
@@ -636,13 +484,6 @@ class RemoteTailscale(Device):
636
484
  self.tailnet = tailnet
637
485
  self.__qualname__ = "Remote Tailscale"
638
486
 
639
- class Funnel:
640
- LOCAL_HOST = '127.0.0.1'
641
-
642
- def __init__(self, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
643
- self.local_port = local_port
644
- self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
645
-
646
487
  ########
647
488
 
648
489
  class Local:
@@ -651,10 +492,12 @@ class Local:
651
492
 
652
493
  class LocalTailscaleManager(Manager):
653
494
  async def start(self):
654
- await async_tailscale_up()
495
+ if not (await Subprocess.async_tailscale(['up']))[0]:
496
+ raise RuntimeError("Failed to start up local Tailscale")
655
497
 
656
498
  async def stop(self):
657
- await async_tailscale_down()
499
+ if not (await Subprocess.async_tailscale(['down']))[0]:
500
+ raise RuntimeError("Failed to shut down local Tailscale")
658
501
 
659
502
  class LocalTailscale(Manageable):
660
503
  def __init__(self):
@@ -662,12 +505,201 @@ class LocalTailscale(Manageable):
662
505
  self.__qualname__ = "Local Tailscale"
663
506
 
664
507
  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()
508
+ logger.debug("Getting status of %s", LazyStr(self.get_class_name))
509
+ success, stdout = await Subprocess.async_tailscale(['status', '--json', '--peers=false', '--self=true'])
510
+ if success:
511
+ status = json.loads(stdout)
512
+ return success and status['BackendState'] == 'Running' and status['Self']['Online']
667
513
 
668
514
  async def _sleep_while_wait(self, available: bool):
669
515
  await asyncio.sleep(0.250)
670
516
 
517
+ class Funnel(Pingable):
518
+ LOCAL_HOST = '127.0.0.1'
519
+
520
+ def __init__(self, tailscale: RemoteTailscale, machine_name: str, local_port: int, local_path: str, external_port: int):
521
+ self.local_port = local_port
522
+ self.external_name = f'{machine_name}.{tailscale.tailnet}'
523
+ self.external_url = f'https://{machine_name}.{tailscale.tailnet}:{external_port}{local_path}'
524
+
525
+ async def wait_for(self, available: bool, timeout: float):
526
+ self._sleepcounter = 0
527
+ await super().wait_for(available, timeout)
528
+
529
+ async def ping(self, availability_hint: bool | None = None):
530
+ logger.debug("Resolving DNS for %s (%s)", LazyStr(self.get_class_name), self.external_name)
531
+ try:
532
+ # resolve directly at an outside DNS, because local magicDNS will return the tailnet IP
533
+ _answer = await dns.asyncresolver.resolve_at('1.1.1.1', self.external_name)
534
+ except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
535
+ return False
536
+ return True
537
+
538
+ async def _sleep_while_wait(self, available: bool):
539
+ if 0 != self._sleepcounter and 0 == self._sleepcounter % 6:
540
+ logger.info("Waiting for public DNS records to be updated for %s (%s)...", LazyStr(self.get_class_name), self.external_name)
541
+ await asyncio.sleep(10)
542
+ self._sleepcounter += 1
543
+
544
+ ########
545
+
546
+ class Webhooks:
547
+ PING_PATH = 'ping'
548
+ VARIABLE_PATH = 'variable'
549
+
550
+ def __init__(self, host: str, port: int):
551
+ self.host = host
552
+ self.port = port
553
+ self.variables = dict[str, asyncio.Queue[str]]()
554
+
555
+ @staticmethod
556
+ def get_ping_path():
557
+ return f'/{Webhooks.PING_PATH}'
558
+
559
+ @staticmethod
560
+ def get_variable_path(variable: str):
561
+ return f'/{Webhooks.VARIABLE_PATH}/{variable}'
562
+
563
+ async def _start(self):
564
+ async def _ping(request: web.Request):
565
+ return web.Response(text='pong')
566
+ async def _receive_variable(request: web.Request):
567
+ queue = self.variables.get(request.match_info['name'])
568
+ if queue:
569
+ with suppress(asyncio.QueueFull):
570
+ queue.put_nowait(await request.text())
571
+ return web.Response(text='OK')
572
+ app = web.Application()
573
+ app.add_routes([
574
+ web.get(f'/{Webhooks.PING_PATH}', _ping),
575
+ web.post(f'/{Webhooks.VARIABLE_PATH}' + r'/{name}', _receive_variable)])
576
+ self.runner = web.AppRunner(app)
577
+ await self.runner.setup()
578
+ self.site = web.TCPSite(self.runner, host=self.host, port=self.port)
579
+ await self.site.start()
580
+
581
+ async def _stop(self):
582
+ await self.runner.cleanup()
583
+
584
+ def subscribe_variable(self, variable: str):
585
+ if variable not in self.variables:
586
+ self.variables[variable] = asyncio.Queue[str](maxsize=16)
587
+
588
+ def unsubscribe_variable(self, variable: str):
589
+ self.variables.pop(variable)
590
+
591
+ async def get_variable(self, variable: str, timeout: float):
592
+ queue = self.variables.get(variable)
593
+ if not queue:
594
+ raise ValueError(f"The {variable} is unknown")
595
+ try:
596
+ async with asyncio.timeout(timeout):
597
+ return await queue.get()
598
+ except TimeoutError as e:
599
+ e.add_note(f"Can't get value of {variable} for {timeout} seconds")
600
+ raise
601
+
602
+ def __enter__(self):
603
+ raise TypeError("Use async with instead")
604
+ def __exit__(self, exc_type, exc_value, exc_tb):
605
+ pass
606
+ async def __aenter__(self):
607
+ await self._start()
608
+ return self
609
+ async def __aexit__(self, exc_type, exc_value, exc_tb):
610
+ await self._stop()
611
+
612
+ class Automate:
613
+ def __init__(self, secrets: Secrets, session: aiohttp.ClientSession, account: str, device: str, tokenfile: str):
614
+ self.session = session
615
+ self.account = account
616
+ self.device = device
617
+ self.secret = secrets.get(tokenfile)
618
+
619
+ async def send_message(self, message: str):
620
+ data = {
621
+ "secret": self.secret,
622
+ "to": self.account,
623
+ "device": self.device,
624
+ "priority": "high",
625
+ "payload": f"prim-ctrl;{time.time()};" + message
626
+ }
627
+ logger.debug("Messaging Automate with: %s", message)
628
+ async with self.session.post(f'https://llamalab.com/automate/cloud/message', json=data) as response:
629
+ await response.text()
630
+
631
+ class AutomatepFTPdManager(Manager):
632
+ def __init__(self, automate: Automate):
633
+ self.automate = automate
634
+
635
+ async def start(self):
636
+ await self.automate.send_message('start-pftpd')
637
+
638
+ async def stop(self):
639
+ await self.automate.send_message('stop-pftpd')
640
+
641
+ class AutomateTailscaleManager(Manager):
642
+ def __init__(self, automate: Automate):
643
+ self.automate = automate
644
+
645
+ async def start(self):
646
+ await self.automate.send_message('start-tailscale')
647
+
648
+ async def stop(self):
649
+ await self.automate.send_message('stop-tailscale')
650
+
651
+ class AutomatePhoneState(PhoneState):
652
+ VARIABLE_STATE = 'state'
653
+
654
+ def __init__(self, session: aiohttp.ClientSession, webhooks: Webhooks, automate: Automate, funnel: Funnel):
655
+ self.session = session
656
+ self.webhooks = webhooks
657
+ self.automate = automate
658
+ self.funnel = funnel
659
+
660
+ async def get(self, repeat: float, timeout: float):
661
+ logger.info("Getting Phone state...")
662
+
663
+ # test funnel + webhooks availability, to not wait for a reply if funnel isn't configured properly
664
+ # 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
665
+ test_timeout = 10.0
666
+ logger.debug("Testing Funnel with calling local webhook (timeout is %ds)", int(test_timeout))
667
+ try:
668
+ async with self.session.get(f'{self.funnel.external_url}{Webhooks.get_ping_path()}', timeout=ClientTimeout(total=test_timeout)) as response:
669
+ if await response.text() != 'pong':
670
+ raise Exception()
671
+ except Exception as e:
672
+ raise RuntimeError(f"Local Funnel is not configured properly for {self.funnel.external_url}") from e
673
+
674
+ # test funnel's DNS resolvability, if local Tailscale is freshly started up after longer down state, it can take up to 10 minutes for public DNS records to get updated
675
+ test_timeout = 600.0
676
+ logger.debug("Testing Funnel's DNS configuration (timeout is %ds)", int(test_timeout))
677
+ try:
678
+ await self.funnel.wait_for(True, test_timeout)
679
+ except Exception as e:
680
+ raise RuntimeError(f"Funnel's DNS is not configured by Tailscale for {self.funnel.external_name}") from e
681
+
682
+ # get state
683
+ logger.debug("Getting Phone state (repeat after %ds, timeout is %ds)", int(repeat), int(timeout))
684
+ self.webhooks.subscribe_variable(AutomatePhoneState.VARIABLE_STATE)
685
+ try:
686
+ async with asyncio.timeout(timeout):
687
+ while True:
688
+ try:
689
+ await self.automate.send_message(f'get-state;{self.funnel.external_url}{Webhooks.get_variable_path(AutomatePhoneState.VARIABLE_STATE)}')
690
+ state = await self.webhooks.get_variable(AutomatePhoneState.VARIABLE_STATE, min(repeat, timeout))
691
+ break
692
+ except TimeoutError:
693
+ pass
694
+ except TimeoutError as e:
695
+ 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")
696
+ raise
697
+ finally:
698
+ self.webhooks.unsubscribe_variable(AutomatePhoneState.VARIABLE_STATE)
699
+
700
+ logger.info("Phone state is %s", state)
701
+ return StateSerializer.loads(state)
702
+
671
703
  ########
672
704
 
673
705
  class WideHelpFormatter(argparse.RawTextHelpFormatter):
@@ -924,7 +956,7 @@ class AutomateControl(Control):
924
956
 
925
957
  async with Webhooks(Funnel.LOCAL_HOST, funnel.local_port) if funnel else nullcontext() as webhooks:
926
958
  local = Local(local_tailscale)
927
- automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel.external_url) if funnel and webhooks else None
959
+ automate_phone_state = AutomatePhoneState(session, webhooks, automate, funnel) if funnel and webhooks else None
928
960
  phone = Phone(zeroconf_pftpd, remote_tailscale, remote_pftpd, automate_phone_state)
929
961
  await self.execute(args, local, phone)
930
962
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "prim-ctrl"
3
- version = "0.6.1"
3
+ version = "0.6.2"
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>"]
@@ -18,9 +18,10 @@ packages = [{include = "prim_ctrl"}]
18
18
 
19
19
  [tool.poetry.dependencies]
20
20
  python = "^3.12"
21
- aiohttp = "^3.11.0"
21
+ aiohttp = "^3.11.10"
22
22
  platformdirs = "^4.3.6"
23
- zeroconf = "^0.136.0"
23
+ zeroconf = "^0.136.2"
24
+ dnspython = "^2.7.0"
24
25
 
25
26
  [tool.poetry.scripts]
26
27
  prim-ctrl = "prim_ctrl.__main__:run"
File without changes