From 1e1f2a40acc35a410418c5d3b9a5e2d459af5aff Mon Sep 17 00:00:00 2001 From: Nova <126072875+nova-r@users.noreply.github.com> Date: Sat, 25 Feb 2023 19:26:12 +0100 Subject: [PATCH] Add buttplugio device integration; Remove queue; Update dependencies This is the first rudementary buttplugio integration that actually works. Use with caution. I still want to add the queue back and currently the duration and length of vibration is hard-coded. --- .gitignore | 3 +- fediplug/__init__.py | 2 +- fediplug/buttplugio.py | 106 +++++++++++++++++++++++++++++++++++++++++ fediplug/cli.py | 47 +++++------------- fediplug/mastodon.py | 74 ++++++++++++++-------------- fediplug/queue.py | 105 ++++++++++++---------------------------- setup.py | 7 ++- 7 files changed, 196 insertions(+), 148 deletions(-) create mode 100644 fediplug/buttplugio.py diff --git a/.gitignore b/.gitignore index af999e8..d1cbb27 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ .pytest_cache .vscode __pycache__ -fediplay.iml +fediplug.iml +venv \ No newline at end of file diff --git a/fediplug/__init__.py b/fediplug/__init__.py index b3281a2..248857c 100644 --- a/fediplug/__init__.py +++ b/fediplug/__init__.py @@ -1,3 +1,3 @@ -'''A Mastodon client for playing your friends' music.''' +'''A Mastodon client that automatically plays your friends' music as they toot links to it.''' from fediplug.cli import cli diff --git a/fediplug/buttplugio.py b/fediplug/buttplugio.py new file mode 100644 index 0000000..6105fb5 --- /dev/null +++ b/fediplug/buttplugio.py @@ -0,0 +1,106 @@ +'''Buttplug controller''' + +import asyncio +import logging +import sys + +from buttplug import Client, WebsocketConnector, ProtocolSpec + +from fediplug.cli import options +import fediplug.env as env + +async def connect_plug_client(): + '''create Client object and connect plug client to Intiface Central or similar''' + # And now we're in the main function. First, we'll need to set up a client + # object. This is our conduit to the server. + # We create a Client object, passing it the name we want for the client. + # Names are shown in things like Intiface Central. We also can specify the + # protocol version we want to use, which will default to the latest version. + plug_client = Client("fediplug", ProtocolSpec.v3) + + # Now we have a client called "Test Client", but it's not connected to + # anything yet. We can fix that by creating a connector. Connectors + # allow clients to talk to servers through different methods, including: + # - Websockets + # - IPC (Not currently available in Python) + # - WebRTC (Not currently available in Python) + # - TCP/UDP (Not currently available in Python) + # For now, all we've implemented in python is a Websocket connector, so + # we'll use that. This connector will connect to Intiface Central/Engine + # on the local machine, using the 12345 port for insecure websockets. + # We also pass the client logger so that it is used as the parent. + connector = WebsocketConnector("ws://127.0.0.1:12345", logger=plug_client.logger) + + # Finally, we connect. + # If this succeeds, we'll be connected. If not, we'll probably have some + # sort of exception thrown of type ButtplugError. + try: + await plug_client.connect(connector) + except Exception as e: + logging.error(f"Could not connect to server, exiting: {e}") + return + print('plug client connected') + return plug_client + +async def scan_devices(plug_client): + # Now we move on to looking for devices. We will tell the server to start + # scanning for devices. It returns while it is scanning, so we will wait + # for 10 seconds, and then we will tell the server to stop scanning. + await plug_client.start_scanning() + await asyncio.sleep(5) + await plug_client.stop_scanning() + + # We can use the found devices as we see fit. The list of devices is + # automatically kept up to date by the client: + + # First, we are going to list the found devices. + plug_client.logger.info(f"Devices: {plug_client.devices}") + + # If we have any device we can print the list of devices + # and access it by its ID: ( this step is done is trigger_actuators() ) + if len(plug_client.devices) != 0: + print(plug_client.devices) + print(len(plug_client.devices), "devices found") + return plug_client + + +async def trigger_actuators(plug_client, play_command): + # If we have any device we can access it by its ID: + device = plug_client.devices[0] + # The most common case among devices is that they have some actuators + # which accept a scalar value (0.0-1.0) as their command. + play_command = (1, 1) + if len(device.actuators) != 0: + print(len(device.actuators), "actuators found") + # cycle through all actuators in device + print(device.actuators) + for actuator in device.actuators: + await actuator.command(play_command[0]) + print("generic actuator") + await asyncio.sleep(play_command[1]) + #stops all actuators + for actuator in device.actuators: + await actuator.command(0) +''' + # Some devices may have linear actuators which need a different command. + # The first parameter is the time duration in ms and the second the + # position for the linear axis (0.0-1.0). + if len(device.linear_actuators) != 0: + await device.linear_actuators[0].command(1000, 0.5) + print("linear actuator") + + # Other devices may have rotatory actuators which another command. + # The first parameter is the speed (0.0-1.0) and the second parameter + # is a boolean, true for clockwise, false for anticlockwise. + if len(device.rotatory_actuators) != 0: + await device.rotatory_actuators[0].command(0.5, True) + print("rotary actuator") +''' + +async def disconnect_plug_client(plug_client): + # Disconnect the plug_client. + await plug_client.disconnect() + + +# First things first. We set logging to the console and at INFO level. +logging.basicConfig(stream=sys.stdout, level=logging.INFO) diff --git a/fediplug/cli.py b/fediplug/cli.py index 1540139..3b64aba 100644 --- a/fediplug/cli.py +++ b/fediplug/cli.py @@ -6,23 +6,18 @@ import os path = os.path import sys +import atexit import appdirs -import click +import anyio +import click as click import atexit from mastodon import Mastodon +import asyncio from fediplug.dirs import DIRS import fediplug.mastodon as mastodon import fediplug.keyring as keyring - -def ensure_dirs(): - '''Make sure the application directories exist.''' - - if not path.exists(DIRS.user_config_dir): - os.makedirs(DIRS.user_config_dir) - - if not path.exists(DIRS.user_cache_dir): - os.makedirs(DIRS.user_cache_dir) +import fediplug.buttplugio as buttplugio def get_access_token(instance): '''Ensure the user credential exists.''' @@ -30,7 +25,7 @@ def get_access_token(instance): keyring.migrate_access_token(instance) if not keyring.has_credential(instance, keyring.CREDENTIAL_ACCESS_TOKEN): - click.echo('user credential for {} does not exist; try `fediplug login`'.format(instance)) + click.echo(f'user credential for {instance} does not exist; try `fediplug login`') sys.exit(1) return keyring.get_credential(instance, keyring.CREDENTIAL_ACCESS_TOKEN) @@ -42,7 +37,7 @@ def get_client_credentials(instance): if not (keyring.has_credential(instance, keyring.CREDENTIAL_CLIENT_ID) and keyring.has_credential(instance, keyring.CREDENTIAL_CLIENT_SECRET)): - click.echo('client credentials for {} do not exist; try `fediplug register`'.format(instance)) + click.echo(f'client credentials for {instance} do not exist; try `fediplug register`') sys.exit(1) return ( @@ -57,8 +52,6 @@ def cli(debug): options['debug'] = debug - ensure_dirs() - @cli.command() @click.argument('instance') def register(instance): @@ -85,27 +78,13 @@ def login(instance): @cli.command() @click.argument('instance') @click.argument('users', nargs=-1) -@click.option('--clean-up-files', is_flag=True) -def stream(instance, users, clean_up_files): - '''Stream music from your timeline.''' - if ( clean_up_files ): - atexit.register(delete_files) +def stream(instance, users): + '''control buttplug.io device from your timeline.''' + event_loop = asyncio.get_event_loop() + plug_client = event_loop.run_until_complete(buttplugio.connect_plug_client()) + plug_client = event_loop.run_until_complete(buttplugio.scan_devices(plug_client)) client_id, client_secret = get_client_credentials(instance) access_token = get_access_token(instance) - mastodon.stream(instance, users, client_id, client_secret, access_token, cache_dir=DIRS.user_cache_dir) - -def delete_files(): - cache_dir = DIRS.user_cache_dir - for the_file in os.listdir(cache_dir): - file_path = os.path.join(cache_dir, the_file) - if os.path.isfile(file_path): - os.remove(file_path) - print('deleted ' + the_file) - -@cli.command() -def clean_up_files(): - delete_files() - - + mastodon.stream(instance, users, client_id, client_secret, access_token, plug_client, event_loop) diff --git a/fediplug/mastodon.py b/fediplug/mastodon.py index 7c21a9e..2a74e17 100644 --- a/fediplug/mastodon.py +++ b/fediplug/mastodon.py @@ -5,13 +5,15 @@ LISTEN_TO_HASHTAG = 'fediplug' from os import umask import click -from lxml.etree import HTML # pylint: disable=no-name-in-module +import lxml.html as lh +from lxml.html.clean import clean_html import mastodon -from youtube_dl.utils import DownloadError +import asyncio from fediplug.cli import options import fediplug.keyring as keyring -from fediplug.queue import Queue +#from fediplug.queue import Queue +from fediplug.buttplugio import trigger_actuators Mastodon = mastodon.Mastodon @@ -22,19 +24,20 @@ def api_base_url(instance): return 'https://' + instance class StreamListener(mastodon.StreamListener): - '''Listens to a Mastodon timeline and adds links the given Queue.''' + '''Listens to a Mastodon timeline and adds buttplug instructions the given queue.''' - def __init__(self, queue, instance, users): - self.queue = queue + def __init__(self, plug_client, instance, users, event_loop): + self.plug_client = plug_client self.instance = instance self.users = users + self.event_loop = event_loop if options['debug']: - print('listener initialized with users={!r}'.format(self.users)) + print(rf'listener initialized with users={self.users}') def on_update(self, status): if options['debug']: - print('incoming status: acct={!r}'.format(status.account.acct)) + print(rf'incoming status: acct={status.account.acct}') if self.users and normalize_username(status.account.acct, self.instance) not in self.users: if options['debug']: @@ -43,20 +46,29 @@ class StreamListener(mastodon.StreamListener): tags = extract_tags(status) if options['debug']: - print('expecting: {!r}, extracted tags: {!r}'.format(LISTEN_TO_HASHTAG, tags)) + print(rf'expecting: {LISTEN_TO_HASHTAG}, extracted tags: {tags}') if LISTEN_TO_HASHTAG in tags: - links = extract_links(status) + ''' Here we extract the instructions for the butplug''' + # TODO: still need to write extraction code + buttplug_instructions = extract_buttplug_instructions(status) + click.echo('queueing instructions') + self.event_loop.run_until_complete(trigger_actuators(self.plug_client, buttplug_instructions)) + + + +''' if options['debug']: - print('links: {!r}'.format(links)) + print(rf'instructions: {buttplug_instructions}') for link in links: try: - click.echo('==> Trying {}'.format(link)) + click.echo(rf'==> Trying {link}') self.queue.add(link) return except DownloadError: pass +''' def register(instance): '''Register fediplug to a Mastodon server and save the client credentials.''' @@ -83,22 +95,23 @@ def login(instance, client_id, client_secret, grant_code): access_token = client.log_in(code=grant_code, scopes=['read']) keyring.set_credential(instance, keyring.CREDENTIAL_ACCESS_TOKEN, access_token) -def stream(instance, users, client_id, client_secret, access_token, cache_dir='.'): +def stream(instance, users, client_id, client_secret, access_token, plug_client, event_loop): '''Stream statuses and add them to a queue.''' client = build_client(instance, client_id, client_secret, access_token) users = [normalize_username(user, instance) for user in users] - listener = StreamListener(Queue(cache_dir), instance, users) + listener = StreamListener(plug_client, instance, users, event_loop) + existing_statuses = client.timeline_hashtag(LISTEN_TO_HASHTAG, limit=1) if options['debug']: - print('existing_statuses: {!r}'.format(existing_statuses)) + print(rf'existing_statuses: {existing_statuses}') for status in existing_statuses: listener.on_update(status) - click.echo('==> Streaming from {}'.format(instance)) + click.echo(f'==> Streaming from {instance}') client.stream_user(listener) def extract_tags(toot): @@ -110,29 +123,18 @@ def normalize_username(user, instance): user = user.lstrip('@') parts = user.split('@') if options['debug']: - print('parts: {!r}'.format(parts)) + print(rf'parts: {parts}') if len(parts) == 1 or parts[1] == instance: return parts[0] else: return user -def link_is_internal(link): - '''Determines if a link is internal to the Mastodon instance.''' - - classes = link.attrib.get('class', '').split(' ') - - if options['debug']: - print('href: {!r}, classes: {!r}'.format(link.attrib['href'], classes)) - - if classes: - return 'mention' in classes - - return False - -def extract_links(toot): - '''Extract all external links from a toot.''' - - html = HTML(toot['content']) - all_links = html.cssselect('a') - return [link.attrib['href'] for link in all_links if not link_is_internal(link)] +def extract_buttplug_instructions(toot): + '''Extract buttplug instruction informations from a toot.''' + doc_list = [] + doc = lh.fromstring(toot['content']) + doc = clean_html(doc) + doc_list.append(doc.text_content()) + print(rf'extracted buttplug_instruction: {doc_list}') + return doc_list \ No newline at end of file diff --git a/fediplug/queue.py b/fediplug/queue.py index 89d07ed..8e48420 100644 --- a/fediplug/queue.py +++ b/fediplug/queue.py @@ -2,118 +2,73 @@ from os import path, listdir, makedirs, remove, utime from time import time, localtime -from subprocess import Popen, run from threading import Thread, Lock import click -from youtube_dl import YoutubeDL, utils +import asyncio from fediplug.cli import options import fediplug.env as env +import fediplug.buttplugio as buttplugio + + +'''--deprecated--''' class Queue(object): '''The play queue.''' # pylint: disable=too-few-public-methods - def __init__(self, cache_dir): + def __init__(self, plug_client): self.lock = Lock() self.playing = False self.queue = [] - self.cache_dir = cache_dir + self.plug_client = plug_client - def add(self, url): - '''Fetches the url and adds the resulting audio to the play queue.''' - - filenames = Getter(self.cache_dir).get(url) + def add(self, buttplug_instructions): + '''adds the buttplug instructions to the play queue.''' with self.lock: - self.queue.extend(filenames) + self.queue.extend(buttplug_instructions) if not self.playing: self._play(self.queue.pop(0), self._play_finished) - def _play(self, filename, cb_complete): + def _play(self, buttplug_instructions, cb_complete): self.playing = True - def _run_thread(filename, cb_complete): - play_command = build_play_command(filename) + def _run_thread(buttplug_instructions, cb_complete): + play_command = build_play_command(buttplug_instructions) if options['debug']: - click.echo(f'==> Playing {filename} with {play_command}') + click.echo(f'==> Playing {buttplug_instructions} with {play_command}') else: - click.echo(f'==> Playing {filename}') - run(play_command) - + click.echo(f'==> Playing {buttplug_instructions}') + """ + loop = asyncio.new_event_loop + asyncio.run_coroutine_threadsafe(buttplugio.trigger_actuators(self.plug_client, play_command), loop) + # run command THIS DOES NOT WORK RIGHT NOW ##### THIS REALLY NEEDS TO BE FIXED + #loop = asyncio.events._get_running_loop() + #print(loop) """ + + print('foo') + click.echo('==> Playback complete') cb_complete() - thread = Thread(target=_run_thread, args=(filename, cb_complete)) + thread = Thread(target=_run_thread, args=(buttplug_instructions, cb_complete)) thread.start() + def _play_finished(self): with self.lock: self.playing = False if self.queue: self._play(self.queue.pop(0), self._play_finished) -class Getter(object): - '''Fetches music from a url.''' - # pylint: disable=too-few-public-methods - def __init__(self, cache_dir): - self.filename = None - self.filenames = [] - self.cache_dir = cache_dir - - def _progress_hook(self, progress): - if options['debug']: - print('progress hook: status {!r}, filename {!r}'.format(progress['status'], progress['filename'])) - - if (progress['status'] in ('downloading', 'finished') and - progress['filename'] not in self.filenames): - self.filenames.append(progress['filename']) - - def get(self, url): - '''Fetches music from the given url.''' - - '''deleting files here''' - auto_delete_files(self.cache_dir) - - ytdl_options = { - 'format': 'mp3/mp4', - 'nocheckcertificate': env.no_check_certificate(), - 'outtmpl': path.join(self.cache_dir, utils.DEFAULT_OUTTMPL), - 'progress_hooks': [self._progress_hook], - 'restrictfilenames': True, - 'quiet': True, - 'no_warnings': True - } - - if options['debug']: - ytdl_options['quiet'] = False - ytdl_options['no_warnings'] = False - - with YoutubeDL(ytdl_options) as downloader: - downloader.download([url]) - - for file in self.filenames: - utime(file) - - return self.filenames - -def build_play_command(filename): +def build_play_command(buttplug_instructions): '''Builds a play command for the given filename.''' + # hardcoded for now + # return tuple with ( strength [0.0-1.0] , duration [in s] ) - filename = rf"{filename}" - - command_args = env.play_command().split() - command_args.append(filename) - return command_args - -def auto_delete_files(cache_dir): - for the_file in listdir(cache_dir): - file_path = path.join(cache_dir, the_file) - if path.isfile(file_path): - file_time = path.getmtime(file_path) - if file_time + 604800 < time(): - remove(file_path) \ No newline at end of file + return (0.5, 1) diff --git a/setup.py b/setup.py index 14a1bf1..5e79729 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,12 @@ setup( 'lxml', 'Mastodon.py', 'python-dotenv', - 'youtube-dl' + 'asyncio', + 'asyncclick', + 'anyio', + 'keyring', + 'buttplug-py' + ], entry_points={ 'console_scripts': [