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.
This commit is contained in:
Nova 2023-02-25 19:26:12 +01:00
parent 7d18f6cf91
commit 1e1f2a40ac
7 changed files with 196 additions and 148 deletions

3
.gitignore vendored
View file

@ -8,4 +8,5 @@
.pytest_cache
.vscode
__pycache__
fediplay.iml
fediplug.iml
venv

View file

@ -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

106
fediplug/buttplugio.py Normal file
View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)
return (0.5, 1)

View file

@ -11,7 +11,12 @@ setup(
'lxml',
'Mastodon.py',
'python-dotenv',
'youtube-dl'
'asyncio',
'asyncclick',
'anyio',
'keyring',
'buttplug-py'
],
entry_points={
'console_scripts': [