home.social

#platypush — Public Fediverse posts

Live and recent posts from across the Fediverse tagged #platypush, aggregated by home.social.

fetched live
  1. 📢 New Blog Article

    If you’re tired of getting spied through your #weather app, but you still want all the features of your commercial/spyware weather apps, then #Platypush has a few solutions to get you covered.

  2. Turn Platypush into your favourite weather app

    Mobile app, web app and custom handling of weather events, all in one

    #weather apps are one of those things that nowadays we take for granted, and most of us consider a largely solved problem.

    After all, a weather app mostly consists in a simple UI that fetches the weather conditions from some API, optionally by retrieving your current location, and then it displays the information to the user.

    Optionally, it can provide little perks like weather notifications.

    The average usual app is simple and boring, and it's usually nothing that a decent student at the last year of engineering college wouldn't be able to put together in a weekend.

    And yet, because of how easy it is to build a simple weather app, how pervasive these apps are on everyone's phones, and the amount of data that they collect about the user (which vastly justifies the small development cost), weather apps have become a favourite vector for tracking users.

    They are also favourite ads delivery instruments.

    The American government itself uses weather apps to track its citizens, and ICE buys data from weather apps to track potential targets.

    After all, weather apps are among the few apps that:

    • Are easy to build and deploy
    • Are usually installed by everyone who has a phone
    • Continuously collect location data about users

    What if it could be different?

    Of course, there are many weather apps on F-Droid that actually do a decent job respecting users' privacy, and if you want something simple that only runs on your phone many of them may match your needs. But in my opinion that only solves part of the problem.

    Those apps still need to be installed on each of your mobile or tablet devices. And on desktop you'll need a different service anyway.

    And any weather notifications will be limited to the mobile device that receives them, which limits what you can do with them - what if I want to use my weather app to record all the weather measurements in a certain area? What if I also want to send a message on my family's messaging group if it starts snowing in my area?

    This is where a weather solution based on Platypush comes handy.

    Pros:

    • Fully self-hosted. Run the #platypush service on any device that can run a #python interpreter, from a cheap VPS to a RaspberryPi, expose it over an HTTPS URL, and any device can use the app.

    • No mobile apps required. Platypush provides a Progressive Web Application (PWA), which means that you can open the Web interface from your browser, install the PWA directly from your browser, and have it on your home screen just like a native app.

    • Full control over weather notifications. Weather updates are handled as standard Platypush events, which means that you can write your own custom hooks to deliver notifications, store weather measurements, send notifications over other channels, and so on.

    Getting started

    The first step is to get an OpenWeatherMap API key.

    Then create a simple configuration for Platypush under /your/platypush/config/config.yaml:

    # Enable the Web server
    backend.http:
        port: 8008
    
    # Enable the OpenWeatherMap plugin
    weather.openweathermap:
        token: YOUR_OPENWEATHERMAP_API_KEY
        # Specify a location by name
        location: Amsterdam,NL
        # Or by coordinates
        # lat: 52.372829
        # long: 4.893680
    
        # How often the plugin should check for new data, in seconds
        poll_interval: 300
    
        # metric or imperial units
        # units: metric
    

    Then install Platypush, or run it directly through the Docker image:

    docker run --rm \
        --name platypush \
        -p 8008:8008 \
        -v /your/platypush/config:/etc/platypush \
        -v /your/platypush/data:/var/lib/platypush \
        -e PLATYPUSH_DEVICE_ID=WeatherApp \
        quay.io/platypush/platypush
    

    Once started, you can open the Web interface at http://localhost:8008 to register your user.

    [Web registration panel]

    Once logged in, you can click on the weather.openweathermap tab from the left menu to immediately access your weather forecast:

    [Weather tab screenshot]

    HTTPS configuration

    A PWA requires an HTTPS connection, or the Web service to be installed on localhost.

    The localhost installation of Platypush is also possible on Android via Termux, but it's out of the scope of this article.

    We can use a reverse proxy and Certbot to make the Web interface available at e.g. https://weather.platypush.example.com:

    This requires:

    • A registered domain name (e.g. weather.platypush.example.com)
    • A box with a public IP (e.g. a VPS)
    • A reverse proxy (e.g. nginx) installed on the box
    • Certbot installed on the box to generate and maintain the SSL certificate

    [Optional] Set up a VPN for the reverse proxy

    This step is optional.

    You can also run the Platypush weather service on the same box as the reverse proxy, and in such cases you don't need to set up a VPN for the reverse proxy.

    If your Platypush service runs on the same machine as the reverse proxy, you can skip to Reverse proxy configuration.

    Otherwise, It's recommended if you want your reverse proxy to tunnel HTTP requests to e.g. your RaspberryPi or old Android tablet at home that runs the Platypush service, without exposing those IP addresses directly to the Internet.

    A quick solution involves setting up your machine with a public IP to also run a Wireguard tunnel to your local machine, so the reverse proxy can directly access your local Platypush installation without leaking your own IP to the Internet.

    Server configuration

    A common set up, if your machine runs Linux with systemd, involves using the wg-quick utility to create a Wireguard tunnel, and then setting up a systemd service to start the tunnel at boot time.

    wg-quick is usually provided by the wireguard-tools package on most of the UNIX-like installations.

    Wireguard peers authenticate each other through public keys. In this example:

    • The VPS/reverse proxy gets the VPN address 10.0.0.1.
    • The Platypush machine gets the VPN address 10.0.0.2.
    • The server only accepts one client, the one whose public key is listed in the server's [Peer] section.

    Start by generating the server keypair on the VPS:

    sudo install -d -m 700 /etc/wireguard
    
    # Generate the server key
    wg genkey | sudo tee /etc/wireguard/private.key > /dev/null
    sudo chmod 600 /etc/wireguard/private.key
    
    # Generate a public key
    sudo cat /etc/wireguard/private.key | wg pubkey | sudo tee /etc/wireguard/public.key
    
    # Print this value. You will need it in the client configuration.
    sudo cat /etc/wireguard/public.key
    

    Then generate the client keypair on the Platypush machine:

    sudo install -d -m 700 /etc/wireguard
    
    # Generate the client key
    wg genkey | sudo tee /etc/wireguard/private.key > /dev/null
    sudo chmod 600 /etc/wireguard/private.key
    
    # Generate a public key
    sudo cat /etc/wireguard/private.key | wg pubkey | sudo tee /etc/wireguard/public.key
    
    # Print this value. You will need it in the server configuration.
    sudo cat /etc/wireguard/public.key
    

    Now go back to the VPS and create the Wireguard server configuration.

    Replace <The public key of the client> with the public key printed by the Platypush machine in the previous step:

    CLIENT_PUBLIC_KEY="<The public key of the client>"
    SERVER_PRIVATE_KEY="$(sudo cat /etc/wireguard/private.key)"
    
    sudo tee /etc/wireguard/wg0.conf > /dev/null <<EOF
    [Interface]
    PrivateKey = ${SERVER_PRIVATE_KEY}
    Address = 10.0.0.1/32
    ListenPort = 9929
    
    [Peer]
    PublicKey = ${CLIENT_PUBLIC_KEY}
    AllowedIPs = 10.0.0.2/32
    EOF
    
    unset SERVER_PRIVATE_KEY
    sudo chmod 600 /etc/wireguard/wg0.conf
    

    The AllowedIPs = 10.0.0.2/32 line is important: it tells Wireguard that only the peer with the configured client public key is allowed to use the 10.0.0.2 tunnel address. Unknown clients, or clients with a different private key, won't be able to complete the tunnel handshake.

    If your VPS firewall blocks inbound traffic by default, allow the Wireguard UDP port. For example, with ufw:

    sudo ufw allow 9929/udp comment 'Wireguard'
    

    Now start the tunnel on the VPS:

    # Enable and start the tunnel
    sudo systemctl enable --now wg-quick@wg0
    
    # Verify that the tunnel is up
    sudo wg show
    sudo systemctl status wg-quick@wg0
    

    Client configuration

    The Platypush machine now needs a configuration that points back to the VPS.

    Run the following commands on your Platypush machine:

    SERVER_PUBLIC_KEY="<The public key of the server>"
    SERVER_ENDPOINT="<The public IP or DNS name of the server>"
    CLIENT_PRIVATE_KEY="$(sudo cat /etc/wireguard/private.key)"
    
    sudo tee /etc/wireguard/wg0.conf > /dev/null <<EOF
    [Interface]
    PrivateKey = ${CLIENT_PRIVATE_KEY}
    Address = 10.0.0.2/32
    
    [Peer]
    PublicKey = ${SERVER_PUBLIC_KEY}
    AllowedIPs = 10.0.0.1/32
    Endpoint = ${SERVER_ENDPOINT}:9929
    PersistentKeepalive = 25
    EOF
    
    unset CLIENT_PRIVATE_KEY
    sudo chmod 600 /etc/wireguard/wg0.conf
    
    sudo systemctl enable --now wg-quick@wg0
    
    # Verify the tunnel and the route to the reverse proxy.
    sudo wg show
    ping -c 3 10.0.0.1
    

    AllowedIPs = 10.0.0.1/32 keeps the client configuration narrow: only traffic for the VPN address of the reverse proxy goes through this tunnel. PersistentKeepalive = 25 is useful when the Platypush machine is behind a home router or mobile NAT, because it keeps the tunnel mapping alive so the reverse proxy can reach 10.0.0.2:8008.

    Once the client is up, verify from the VPS that the reverse proxy can reach the Platypush Web service through the tunnel:

    ping -c 3 10.0.0.2
    curl -I http://10.0.0.2:8008/
    

    Reverse proxy

    Assuming that these conditions are met, proceed with creating a reverse proxy configuration for the Platypush Web interface:

    upstream platypush-weather {
      # Or just 127.0.0.1:8008 if the Platypush service runs on the same box
      server 10.0.0.2:8008;
    }
    
    server {
      # Replace with your own domain
      # NOTE: The DNS entry of this domain must point to the reverse proxy
      server_name weather.platypush.example.com;
      listen 80;
    
      # Standard HTTP resources
      location / {
          client_max_body_size 5M;
          proxy_read_timeout 60;
          proxy_connect_timeout 60;
          proxy_set_header Host $http_host;
          proxy_set_header X-Real-IP $remote_addr;
          proxy_set_header X-Forwarded-Ssl on;
          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_pass http://platypush-weather;
      }
    
      # WebSockets
      location /ws/ {
          client_max_body_size 5M;
          proxy_set_header Upgrade $http_upgrade;
          proxy_set_header Connection "upgrade";
          proxy_redirect off;
          proxy_http_version 1.1;
          proxy_set_header Host $http_host;
          proxy_pass http://platypush-weather;
      }
    }
    

    Apply the configuration and reload your reverse proxy:

    sudo nginx -t
    sudo nginx -s reload
    

    Then verify that the reverse proxy can reach the Platypush Web service through the tunnel:

    curl -I http://weather.platypush.example.com/
    

    Generate a certificate

    Run the following commands on your reverse proxy machine:

    sudo certbot --nginx -d weather.platypush.example.com
    

    Then verify that the reverse proxy can reach the Platypush Web service through the tunnel over HTTPS:

    curl -I https://weather.platypush.example.com/
    

    Installing the mobile app

    Open https://<weather.platypush.example.com>/plugin/weather.openweathermap from your browser on a mobile device.

    Tap on your browser's menu. You should see an entry like "Add to Home Screen" or "Install app".

    Select to install the app and add it to your home screen.

    [Mobile PWA]

    You can search for other locations directly in the search bar, or use the GPS button to find your current location.

    The GPS access permissions are optional, they are requested directly in your browser and only when you use the app, and there's nothing monitoring your location in the background (unless you actually want to monitor it explicitly).

    [Optional] Handling weather events

    If you only need a weather app that works on desktop and mobile when you want to check it, then you can skip this section.

    If instead you would also like to handle events from the weather service, then you can create Platypush event hooks to handle NewWeatherConditionEvent.

    Every time the Platypush service processed a weather update, it will emit events with the following payload:

    {
      "type": "event",
      "target": "WeatherApp",
      "origin": "WeatherApp",
      "id": "f7c26249a0b4eec5c04831fbfe11cdcd",
      "args": {
        "type": "platypush.message.event.weather.NewWeatherConditionEvent",
        "plugin_name": "weather.openweathermap",
        "summary": "Clear",
        "icon": "01d",
        "precip_intensity": 0,
        "precip_type": null,
        "temperature": 25.46,
        "apparent_temperature": 25.81,
        "humidity": 67,
        "pressure": 1020,
        "wind_speed": 3.58,
        "wind_gust": 6.71,
        "wind_direction": 344,
        "cloud_cover": 5,
        "visibility": 10000.0,
        "sunrise": "2026-07-11T03:31:24+00:00",
        "sunset": "2026-07-11T19:59:52+00:00",
        "units": "metric",
        "time": "2026-07-11T18:52:05"
      }
    }
    

    Values:

    • temperature: temperature in degrees Celsius (if units: metric) or degrees Fahrenheit (if units: imperial)
    • apparent_temperature: apparent temperature in degrees Celsius (if units: metric) or degrees Fahrenheit (if units: imperial)
    • wind_speed: wind speed in meters per second
    • wind_gust: wind gust in meters per second
    • visibility: visibility in meters
    • pressure: pressure in hPa
    • cloud_cover: cloud cover in percentage (0-100)
    • precip_intensity: precipitation intensity in mm/h
    • precip_type: rain, snow or hail

    You can subscribe to these events, for example, for:

    • Sending a notification to your mobile when it starts raining
    • Sending an alert on an XMPP or Matrix channel if the visibility drops below a certain threshold
    • Sending a message to your family's messaging group if it starts snowing in your area
    • Storing the weather data in a database for later analysis

    ntfy

    We'll use ntfy to send notifications to your mobile device, paired with the Platypush ntfy plugin.

    You can install the Android (F-Droid link) or iOS app to receive notifications on your mobile device.

    By default, the app and the Platypush plugin will connect to the default ntfy server (https://ntfy.sh/app).

    That's an option, but in that case make sure to always use authentication or topic names with randomized strings.

    Otherwise, you can run your own ntfy instance to send and receive your notifications on a fully self-hosted setup.

    The easiest way is perhaps through Docker:

    docker run \
      -v /your/ntfy/config:/etc/ntfy \
      -v /your/ntfy/cache:/var/cache/ntfy \
      -p 8080:80 \
      -it \
      binwiederhier/ntfy \
          serve \
          --cache-file /var/cache/ntfy/cache.db
    

    Then create a reverse proxy configuration with a certificate like shown in Reverse proxy configuration.

    Plugin configuration

    Add the ntfy plugin to your config.yaml file for Platypush:

    ntfy:
      # Optional, if using a custom server, otherwise ntfy.sh is used
      # server_url: https://ntfy.example.com
    

    Notifying of precipitation events

    Create a Platypush event hook to send notifications to your mobile device:

    # Content of /your/platypush/config/scripts/weather_notifications.py
    
    import json
    from functools import wraps
    from time import time
    
    from platypush import Variable, cron, run
    from platypush.events.weather import NewWeatherConditionEvent
    
    # The weather notifications topic. Replace `1234` with a random string
    weather_notifications_topic = "weather-notifications-1234"
    
    # A Platypush variable to persist the last weather notification timestamp
    last_weather_state_notification_timestamp = Variable(
        "LAST_WEATHER_STATE_NOTIFICATION_TIMESTAMP"
    )
    
    # A Platypush variable to persist the latest notified weather state,
    # so notifications will be delivered when the state changes, even if
    # they are within the `weather_state_notification_timeout`
    last_notified_weather_state = Variable("LAST_NOTIFIED_WEATHER_STATE")
    
    # The maximum time between weather notifications
    weather_state_notification_timeout = (
        60 * 60 * 60  # At most one weather notification every 30 minutes
    )
    
    
    def notification_throttled(func):
        """
        Prevents the decorated function from running too often, unless the
        weather state has changed since the last notification.
        """
    
        @wraps(func)
        def wrapper(*args, **kwargs):
            event = args[0]
            weather_state = json.dumps(event.args, sort_keys=True)
            last_weather_state = last_notified_weather_state.get()
            notification_is_throttled = (
                time() - float(last_weather_state_notification_timestamp.get() or 0)
                <= weather_state_notification_timeout
            )
    
            if notification_is_throttled and weather_state == last_weather_state:
                return
    
            result = func(*args, **kwargs)
            last_weather_state_notification_timestamp.set(time())
            last_notified_weather_state.set(weather_state)
            return result
    
        return wrapper
    
    
    def precipitation_notification(event: NewWeatherConditionEvent) -> dict | None:
        """
        Returns a notification about precipitation for ntfy, if any.
        """
    
        if (event.precip_intensity or 0) < 0.1:
            return  # Negligible or absent precipitation
    
        precip_type = (event.precip_type or "rain").lower()
        if event.precip_intensity < 2:
            precip_intensity = "light"
            priority = 2
        elif event.precip_intensity < 5:
            precip_intensity = "moderate"
            priority = 3
        else:
            precip_intensity = "heavy"
            priority = 4
    
        if precip_type == "snow":
            precip_icon = "❄️"
        elif precip_type == "hail":
            precip_icon = "🌧️"
        else:
            precip_icon = "💧"
    
        run(
            "ntfy.send_message",
            topic=weather_notifications_topic,
        )
    
        return {
            "title": f"{precip_icon} Weather update",
            "message": f"{precip_intensity.capitalize()} {precip_type} in your area",
            "priority": priority,
        }
    
    
    @hook(NewWeatherConditionEvent)
    @notification_throttled
    def on_weather_update(event: NewWeatherConditionEvent, *_, **__):
        precip = precipitation_notification(event)
        if precip:
            run(
                "ntfy.send_message",
                topic=weather_notifications_topic,
                **precip,
            )
    

    Then install the ntfy app on your mobile device or use the Web interface, and subscribe to the weather-notifications-1234 topic on the configured server.

    You'll be notified whenever there's precipitation in your area.

    Of course, you can modify your hook to deliver any kind of relevant notifications in your area - about wind, temperature, humidity, etc.

    And actions are not limited to ntfy. If you prefer, you can deliver the notification over email, ActivityPub, Matrix, Telegram, SMS, XMPP or anything that has a Platypush plugin.

    Weather summaries

    Another useful feature of many mainstream weather apps is that of a daily summary (usually in the morning) of the weather in a certain location, so you can plan your day accordingly.

    This can be easily achieved too through a Platypush cronjob.

    # Content of /your/platypush/config/scripts/weather_report.py
    
    from datetime import datetime
    
    from platypush import cron, procedure, run
    
    weather_plugin = "weather.openweathermap"
    
    # The weather notifications topic. Replace `1234` with a random string
    weather_notifications_topic = "weather-notifications-1234"
    
    
    @procedure("deliver_weather_report")
    def deliver_weather_report():
        """
        Procedure that sends a weather report to ntfy.
        """
    
        forecast = [
            {
                "icon": weather["icon"],
                "temperature": weather["temperature"],
                "summary": weather["summary"],
                "time": weather["time"],
            }
            for weather in run(f"{weather_plugin}.get_forecast")
            if datetime.fromisoformat(weather["time"]).day == datetime.now().day
        ]
    
        current_weather = {
            "time": datetime.now().isoformat(),
            **run(f"{weather_plugin}.get_current_weather"),
        }
    
        body = ""
        for forecast_entry in [current_weather, *forecast]:
            body += (
                f"`{datetime.fromisoformat(forecast_entry['time']).strftime('%H:%M')}`\n"
                f"**{round(forecast_entry['temperature'])}°** "
                f"![{forecast_entry['summary']}](https://openweathermap.org/payload/api/media/file/{forecast_entry['icon']}.png)\n\n"
            )
    
        run(
            "ntfy.send_message",
            topic=weather_notifications_topic,
            title=f"{round(current_weather['temperature'])}° - {current_weather['summary']}",
            message=body,
            icon=f"https://openweathermap.org/payload/api/media/file/{current_weather['icon']}.png",
            markdown=True,
        )
    
    
    @cron("0 6 * * *")
    def daily_weather_report():
        """
        Run the `deliver_weather_report` procedure every day at 6 AM.
        """
        run("procedure.deliver_weather_report")
    

    Restart the Platypush service, and every day at 6 AM, you'll get a summary of the weather in your area.

    [ntfy weather notification]

    [Optional] Voice Assistant

    A common use-case for voice assistant is to ask information about the weather, and Platypush can cover that too by running a voice assistant directly on your hardware.

    The linked article describes how to run a fully local voice assistant, with local speech-to-text and text-to-speech engines.

    But things also work if you decide to use remote models through the assistant.openai or tts.openai plugins.

    You can use the assistant-sample repository to quickly get started with a Docker image with a Platypush installation configured to run a voice assistant.

    Some sample configuration, using assistant.openwakeword for hotword detection together with assistant.openai and tts.openai:

    assistant.openwakeword:
      detection_sensitivity: 0.3
      models:
        - alexa
    
    openai:
      model: gpt-5.5
      api_key: <YOUR-OPENAI-API-KEY>
    
    assistant.openai:
      tts_plugin: tts.openai
      conversation_start_sound: /usr/share/sounds/assistant.mp3
      input_volume: 110
    
    tts.openai:
      output_volume: 85
    

    You can then add a script with an event hook on SpeechRecognizedEvent and reacts to weather requests:

    # Content of /your/platypush/config/scripts/weather_assistant.py
    
    import json
    from dataclasses import dataclass
    from datetime import datetime
    from time import time
    
    import requests
    
    from platypush import Config, run, __version__ as platypush_version
    from platypush.events.assistant import (
        HotwordDetectedEvent,
        SpeechRecognizedEvent,
    )
    
    ai_plugin = "openai"
    assistant_plugin = "assistant.openai"
    weather_plugin = "weather.openweathermap"
    location_cache: dict[str, tuple[float, float]] = {}
    
    
    @dataclass
    class DefaultLocation:
        """
        A utility class to automatically and lazy manage the default weather
        location.
        """
        _location: str | None = None
    
        @property
        def name(self) -> str | None:
            if self._location:
                return self._location
    
            # Get the weather plugin configuration
            cfg = Config.get().get(weather_plugin)
            if not cfg:
                return None
    
            lat = cfg.get("lat")
            long = cfg.get("long")
            location = cfg.get("location")
    
            # If a location name is configured, use that
            if location:
                return location
    
            # Otherwise, reverse lookup the location from latitude and longitude
            self._location = (
                run(f"{weather_plugin}.reverse_lookup_location", lat=lat, long=long) or {}
            ).get("name")
    
            return self._location
    
    
    default_location = DefaultLocation()
    
    
    @dataclass
    class WeatherRequest:
        """
        A weather forecast request.
        """
    
        location: str
        delta_days: int
    
        def __post_init__(self):
            if self.delta_days < 0:
                raise ValueError("delta_days must be positive")
    
        def get_location_coords(self) -> tuple[float, float] | None:
            """
            Get the coordinates of the location.
            """
    
            # Cache lookup
            coord = location_cache.get(self.location)
            if coord:
                return coord
    
            # Reverse geo lookup
            response = requests.get(
                f"https://nominatim.openstreetmap.org/search?q={self.location}&format=json&limit=1",
                headers={
                    "User-Agent": (
                        f"Mozilla/5.0 (compatible; Platypush/{platypush_version}; "
                        "+https://git.platypush.tech/platypush/platypush)"
                    ),
                },
                timeout=10,
            )
    
            response.raise_for_status()
            geojson = response.json() or []
            if not geojson:
                return None
    
            lat, lng = (geojson[0] or {}).get("lat"), (geojson[0] or {}).get("lon")
            if not (lat and lng):
                return None
    
            location_cache[self.location] = lat, lng
            return lat, lng
    
        def get_time_range(self) -> tuple[datetime, datetime]:
            return (
                datetime.fromtimestamp(time() + self.delta_days * 24 * 60 * 60),
                datetime.fromtimestamp(time() + (self.delta_days + 1) * 24 * 60 * 60),
            )
    
    
    def get_structured_weather_forecast(prompt: str) -> dict:
        """
        Get a structured weather forecast given a free text prompt.
        """
    
        # Parse the free-text request
        request = parse_weather_request(prompt)
        response: dict = {}
    
        if not request:
            return response
    
        # Retrive the location for the weather request
        coord = request.get_location_coords()
        if not coord:
            return response
    
        # Get the weather forecast for the specified location and time frame
        lat, lng = coord
        time_range = request.get_time_range()
        forecast = [
            {
                "location": request.location,
                **weather,
            }
            for weather in run(
                f"{weather_plugin}.get_forecast",
                lat=lat,
                long=lng,
            )
            if time_range[0] <= datetime.fromisoformat(weather["time"]) <= time_range[1]
        ]
    
        # If the user requested the current weather, add it to the response
        is_today_forecast = request.delta_days == 0
        current_weather = None
        if is_today_forecast:
            current_weather = forecast[0]
    
        if current_weather:
            response["now"] = current_weather
    
        # Construct the response
        if is_today_forecast:
            key = "today"
        elif request.delta_days == 1:
            key = "tomorrow"
        else:
            key = f"{request.delta_days}days"
    
        response[key] = forecast
        return response
    
    
    def parse_weather_request(request: str) -> WeatherRequest | None:
        """
        Parse a weather request given a free text prompt.
        """
    
        # Use the OpenAI plugin to parse the free-text user request into a
        # structured request
        request_dict = (
            run(
                f"{ai_plugin}.get_response",
                context=[
                    {
                        "role": "system",
                        "content": (
                            "You are a voice assistant provided with weather requests as free text.\n"
                            "Given the prompt, return a structured JSON representation of the request in the following format: "
                            '{ "type": "weather", "delta_days": 1, "location": "San Francisco" }, '
                            'where both delta_days and location are optional (e.g. if the user simply asks "How\'s the weather?".\n'
                            'If the prompt doesn\'t seem to contain a weather request, return { "type": null }'
                        ),
                    }
                ],
                prompt=request,
            )
            or {}
        )
    
        if request_dict.get("type") != "weather":
            return None
    
        weather_request = WeatherRequest(
            location=request_dict.get("location", default_location.name),
            delta_days=request_dict.get("delta_days", 0),
        )
    
        return weather_request
    
    
    def get_weather_report(user_request: str) -> str | None:
        """
        Get a weather free-text report given a free text prompt.
        """
    
        structured_response = get_structured_weather_forecast(user_request)
        response = None
    
        if structured_response:
            # Use the OpenAI plugin to translate the structured weather forecast
            # response into a free-text report
            response = run(
                f"{ai_plugin}.get_response",
                prompt=json.dumps(structured_response),
                context=[
                    # General weather assistant system prompt
                    {
                        "role": "system",
                        "content": (
                            "You are a weather voice assistant that translates JSON weather reports "
                            "into more informal weather reports. The output reports should be brief and to the "
                            "point, but not miss relevant details from the original report."
                        ),
                    },
                    # JSON structure system prompt
                    {
                        "role": "system",
                        "content": (
                            "If the JSON report contains a 'now' key, that's the current weather. "
                            "Otherwise, it may contain either 'today', 'tomorrow' or '<n>days' in the future. "
                        ),
                    },
                    # Avoid abbreviations
                    {
                        "role": "system",
                        "content": (
                            "Do not use abbreviations in your output text, always use the full text units. "
                            "Also, strip any Markdown formatting from the output text. "
                            "Keep in mind that your output text will be rendered verbatim by a text-to-speech engine."
                        ),
                    },
                    # Degrees and wind speed system prompt
                    {
                        "role": "system",
                        "content": (
                            "Do not say 'Celsius' or 'Fahreneit', only 'degrees'. "
                            "Wind speed is reported in km/h, but don't report the absolute number - "
                            "just if the wind is absent, weak, medium or strong."
                        ),
                    },
                    # Precipitation system prompt
                    {
                        "role": "system",
                        "content": (
                            "Precipitation intensity is expressed in mm/h. Do not report the absolute number. "
                            "Only say if the precipitation is low, medium, high or very intense. "
                            "If absent, don't say anything about precipitations. "
                            "Otherwise, mention the precipitation type too (rain, snow, hail...). "
                            "If any precipitations are present in the forecast, mention their intensity and "
                            "around what time of the day they will happen."
                        ),
                    },
                    # Cloud cover system prompt
                    {
                        "role": "system",
                        "content": (
                            "Cloud cover is reported as a percentage between 0 and 100. "
                            "Do not report the absolute number - only a description of the cloud cover. "
                            "In the forecast, mention a cloud cover description that matches a reasonable average among the data points."
                        ),
                    },
                ],
            )
    
        return response
    
    
    @when(HotwordDetectedEvent)
    def on_hotword_detected():
        """
        Start the conversation when the hotword is detected.
        """
        run(f"{assistant_plugin}.start_conversation")
    
    
    @when(SpeechRecognizedEvent, phrase=".*weather.*")
    def on_weather_request(event):
        """
        Respond to weather requests by intercepting `SpeechRecognizedEvent` events
        that contain a weather request (regex).
        """
        response = (
            get_weather_report(event.phrase) or
            "Sorry, I couldn't find any weather information for that location."
        )
    
        event.assistant.render_response(response)
    

    Voice weather assistant flow

    [diagram 1]

    A full demo of how it looks and sounds like:

  3. 📢 New blog article

    A local #RaspberryPi friendly voice assistant setup with #Platypush.

    The setup (hotword detection, speech-to-text, text-to-speech, local plugins, #AI inference) can run completely on-device.

    Ingredients:

    • openwakeword (hotword detection)
    • vosk (local speech-to-text models)
    • piper (local text-to-speech models)
    • [optional] openai (speech-to-intent processing and free text responses). It can also plug to a local model served via e.g. ollama serve
  4. Build a fully local voice assistant in 2026

    A practical setup for a Raspberry Pi-friendly voice assistant based on Platypush.

    Those who have followed me for a while know of my personal obsession with self-built voice assistants.

    My experiments over the years can be summarized as it follows:

    • 2007: Voxifera, my very first attempt at building a primitive voice assistant using Hidden Markov models. Definitely not good for general-purpose usage, but good enough in 2007 to distinguish between a dozen of simple voice commands.

    • 2019: First voice assistant built on top of Platypush. It used the now deprecated Google Assistant Library on top of a Raspberry Pi with a microphone and a speaker, and it could hook any automation routines and custom commands to it through event hooks.

    • 2020: Second iteration on #platypush, this time supporting other assistant plugins too - Alexa (integration now removed), Snowboy (also removed, since the project is dead), Mozilla DeepSpeech (also removed now, since Mozilla discontinued it), PicoVoice, and mimic3 (the text-to-speech engine built on top of Mycroft, now bankrupt).

    • 2024: Third iteration on Platypush, this time with an enhanced PicoVoice integration and new speech-to-text and text-to-speech plugins based on the OpenAI APIs.

    But it's now 2026, and perhaps both the hardware and the software are now mature enough for fully on-device voice assistants based on fully open solutions likely to stick around for a while.

    In this article we'll wire that gap closed with Platypush:

    The result is not another cloud assistant with a different coat of paint. The hotword engine, speech recognition, command dispatch and speech synthesis can all run on-device. If the openai step points to a local OpenAI-compatible server, then the whole pipeline can stay on your LAN too.

    The pipeline

    The architecture can be summarized as follows:

    [diagram 1]

    Hotword detection ("OK Google", "Alexa" etc.) is a continuous, low-latency workload, and it should not need the network.

    Speech-to-text is also a good fit for local inference: Vosk models are small enough to run on modest hardware, including Raspberry Pis, and they are perfectly adequate for short home automation commands.

    Text-to-speech is another place where local models are good enough nowadays: Piper voices are fast, small and much nicer than the old robotic espeak-style fallback.

    The only optional network-shaped piece is the language model.

    But that is a policy choice, not a requirement of the voice stack.

    Setup

    Clone the assistant sample repository:

    git clone https://git.platypush.tech/platypush/assistant-sample
    cd assistant-sample
    

    Models

    The next step is to download the voice models used by the voice stack.

    Hotword Detection

    When the service starts the first time, it will automatically download all the available models.

    You can then use the following command to list the available models once the service is running:

    curl -s -XPOST \
         -H 'Content-type: application/json' \
         -H "Authorization: Bearer $PLATYPUSH_TOKEN" \
         -d '{"type":"request", "action":"assistant.openwakeword.list_models"}' \
         http://localhost:8008/execute
    

    Where $PLATYPUSH_TOKEN is the token of the user that is running the service.

    You can retrieve it by connecting to http://localhost:8008 when the service starts for the first time. Create your credentials, then select Settings -> Tokens -> Generate API Token.

    Speech-to-text

    A full list of the Vosk voice models is available here.

    Some feedback about the quality of the English models:

    Model Size Notes vosk-model-small-en-us-0.15 40 MB Very fast and lightweight model that can also run on an old Raspberry Pi, but accuracy can be low. vosk-model-en-us-0.22-lgraph 128 MB Reasonably accurate on clear speech and with native speakers, but still small enough to run fine even on a Raspberry Pi. vosk-model-en-us-0.22 1.8 GB Accurate generic US English model. Fast on an laptop or x86 processor, but it may be a bit heavy on a Raspberry Pi.

    Download the selected model to the Docker volume working directory:

    mkdir -p ./workdir/assistant.vosk/models
    cd ./workdir/assistant.vosk/models
    wget "https://alphacephei.com/vosk/models/vosk-model-en-us-0.22-lgraph.zip"
    unzip "vosk-model-en-us-0.22-lgraph.zip"
    rm "vosk-model-en-us-0.22-lgraph.zip"
    

    Text-to-speech

    Download a speech synthesis model from here.

    Audio samples are also available to get an idea of the type of voice before downloading.

    The model usually consists of a *.onnx and a *.onnx.json file. Download both of them to the Docker volume working directory:

    mkdir -p ./workdir/piper_tts
    cd ./workdir/piper_tts
    wget "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/hfc_female/medium/en_US-hfc_female-medium.onnx"
    wget "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/hfc_female/medium/en_US-hfc_female-medium.onnx.json"
    

    Configuration

    Copy and edit the example configuration file.

    cp config/config.example.yaml config/config.yaml
    

    Home automation plugins

    The assistant becomes useful once recognized speech can reach the rest of the house.

    For example, Hue lights:

    light.hue:
      bridge: hue
      groups:
        - Living Room
    

    And MPD/Mopidy for music:

    music.mopidy:
      host: localhost
    
    music.mpd:
      host: localhost
      poll_interval: null
    

    Those are just regular Platypush plugins.

    The assistant does not need special knowledge about Hue, MPD, Chromecast, Zigbee, MQTT or anything else.

    It only needs to emit events; your hooks decide what to do with them.

    Build

    Build the container image for the assistant service:

    docker build -t platypush-voice .
    

    Run

    The assistant needs access to the host microphone and speakers. The container routes ALSA through PulseAudio, so the examples below connect it to a PulseAudio server running on the host.

    Linux

    With PulseAudio or pipewire-pulseaudio installed:

    docker run --rm \
      -e PULSE_SERVER=unix:/run/pulse/native \
      -v /run/user/$(id -u)/pulse/native:/run/pulse/native \
      --name voice-assistant \
      -p 8008:8008 \
      -v ./config:/etc/platypush \
      -v ./workdir:/var/lib/platypush \
      platypush-voice
    

    macOS

    Install and start PulseAudio on the host:

    brew install pulseaudio
    pulseaudio --daemonize=yes --exit-idle-time=-1
    pactl load-module module-native-protocol-tcp \
      auth-anonymous=1 \
      listen=0.0.0.0 \
      port=4713
    

    Then start the container:

    docker run --rm \
      -e PULSE_SERVER=tcp:host.docker.internal:4713 \
      --name voice-assistant \
      -p 8008:8008 \
      -v "$(pwd)/config:/etc/platypush" \
      -v "$(pwd)/workdir:/var/lib/platypush" \
      platypush-voice
    

    If pactl load-module reports that the module is already loaded, you can keep using the existing PulseAudio daemon.

    Windows

    Install PulseAudio for Windows, then create a default.pa file in the same directory as pulseaudio.exe:

    load-module module-waveout sink_name=output source_name=input record=1
    load-module module-native-protocol-tcp auth-anonymous=1 listen=0.0.0.0 port=4713
    set-default-sink output
    set-default-source input
    

    Start PulseAudio from PowerShell:

    .\pulseaudio.exe -F .\default.pa --exit-idle-time=-1
    

    Then start the container from the repository directory:

    docker run --rm `
      -e PULSE_SERVER=tcp:host.docker.internal:4713 `
      --name voice-assistant `
      -p 8008:8008 `
      -v "${PWD}/config:/etc/platypush" `
      -v "${PWD}/workdir:/var/lib/platypush" `
      platypush-voice
    

    Make sure microphone access is enabled for desktop applications under Windows privacy settings, and allow PulseAudio through the firewall if prompted.

    Usage

    Once the service is running, you can start interact with it with voice commands (the default activation word is "Alexa").

    Any questions about the weather will be resolved by the weather plugin if it's been enabled.

    If the music or lights plugins are enabled, they can be controlled with voice commands ("stop the music", "turn on the lights", etc.)

    Otherwise, the assistant will use the openai plugin to respond to your questions, with follow-up turns when the response from OpenAI is also a question.

    Extending the Assistant

    The assistant logic is modeled through simple Platypush hooks under config/scripts.

    You can extend it as you like by defining your own hooks or modifying the existing ones.

    Starting a conversation

    Conversations are started by hooking to the HotwordDetectedEvent.

    import logging
    
    from platypush import run, when
    from platypush.events.assistant import HotwordDetectedEvent
    
    logger = logging.getLogger(__name__)
    ai_plugin = "openai"
    assistant_plugin = "assistant.vosk"
    
    
    @when(HotwordDetectedEvent)
    def on_hotword_detected(event: HotwordDetectedEvent):
        """
        When the hotword is detected, start a conversation.
        """
        logger.info(f"Hotword {event.hotword} detected")
        run(f"{assistant_plugin}.start_conversation")
    

    Deterministic commands

    For common home automation commands, regular event hooks are still the best tool. They are fast, inspectable, and they do not hallucinate.

    from platypush import run, when
    from platypush.events.assistant import SpeechRecognizedEvent
    
    
    @when(SpeechRecognizedEvent, phrase="turn on (the)? lights")
    def turn_on_lights():
        """
        Hook run when the user says "turn on the lights" (regex)
        """
        run("light.hue.on")
    
    
    @when(SpeechRecognizedEvent, phrase="play (the)? music")
    def play_music():
        """
        Hook run when the user says "play the music" (regex)
        """
        run("music.mpd.play")
    
    
    @when(SpeechRecognizedEvent, phrase="set the music volume (to|on|at) ${volume}")
    def set_volume(volume: int):
        """
        Hook run when the user says "set the music volume to ${volume}"
        (regex with parameter).
        """
        run("music.mpd.set_volume", volume=volume)
    

    AI Commands

    If the openai plugin is enabled, you can use it to help you answer questions.

    There are two generic use-cases for voice assistants where an AI plugin is beneficial:

    • Speech to Intent
    • Response fallback

    Speech to Intent

    You may want this for general questions, for commands that do not fit a neat regular expression, or for transforming a raw sentence such as:

    make it a bit darker and reduce the music volume

    into a structured action plan like.

    [
      {
        "action": "light.hue.set_lights",
        "args": {
          "bri": 50
        }
      },
      {
        "action": "music.mpd.set_volume",
        "args": {
          "volume": 20
        }
      }
    ]
    

    An example provided in the assistant sample is that of weather forecasting.

    Note in particular the usage of openai.get_response with a well crafted system prompt that turns a natural language request like:

    What's the weather tomorrow in San Francisco?

    Into:

    {
      "type": "weather",
      "delta_days": 1,
      "location": "San Francisco"
    }
    
    def parse_weather_request(request: str) -> WeatherRequest | None:
        request_dict = (
            run(
                "openai.get_response",
                context=[
                    {
                        "role": "system",
                        "content": (
                            "You are a voice assistant provided with weather requests as free text.\n"
                            "Given the prompt, return a structured JSON representation of the request in the following format: "
                            '{ "type": "weather", "delta_days": 1, "location": "San Francisco" }, '
                            'where both delta_days and location are optional (e.g. if the user simply asks "How\'s the weather?".\n'
                            'If the prompt doesn\'t seem to contain a weather request, return { "type": null }'
                        ),
                    }
                ],
                prompt=request,
            )
            or {}
        )
    
        if request_dict.get("type") != "weather":
            return None
    
        weather_request = WeatherRequest(
            location=request_dict.get("location", default_location),
            delta_days=request_dict.get("delta_days", 0),
        )
    
        return weather_request
    

    You can also use the model for intermediate transformation instead of direct answers. For example, ask it to return a tiny JSON object with action and args, then dispatch only actions you explicitly allow:

    ALLOWED_ACTIONS = {
        "lights.on": "light.hue.on",
        "lights.off": "light.hue.off",
        "music.play": "music.mpd.play",
        "music.stop": "music.mpd.stop",
    }
    
    
    @when(SpeechRecognizedEvent)
    def on_fuzzy_command(event):
        plan = run(
            "openai.get_response",
            prompt=event.phrase,
            context=[
                {
                    "role": "system",
                    "content": (
                        "Map the user command to JSON only: "
                        '{"action": "...", "args": {...}}. '
                        f"Allowed actions: {', '.join(ALLOWED_ACTIONS)}. "
                        "If none match, return {\"action\": null, \"args\": {}}."
                    ),
                }
            ],
        )
    
        # Parse `plan` as JSON here, validate it, then run only an allow-listed action.
    

    That last validation step matters. A model may be useful for interpretation, but it should not get arbitrary access to run().

    Response fallback

    If a request doesn't match any of the commands you have defined, you can use a generic SpeechRecognizedEvent hook to forward the request to an AI plugin, and render the response as speech through the text-to-speech plugin.

    import logging
    
    from platypush import run, when
    from platypush.events.assistant import SpeechRecognizedEvent
    
    logger = logging.getLogger(__name__)
    ai_plugin = "openai"
    assistant_plugin = "assistant.vosk"
    
    
    @when(SpeechRecognizedEvent, plugin=assistant_plugin)
    def on_speech_recognized(event: SpeechRecognizedEvent):
        """
        Generic handler for speech recognition events received
        by the configured assistant plugin.
        """
        logger.info("Recognized speech: %s", event.phrase)
    
        # Forward the request to OpenAI and render the response as speech
        response = run(
            f"{ai_plugin}.get_response",
            prompt=event.phrase,
            context=[
                {
                    "role": "system",
                    "content": (
                        "You are a voice assistant that can answer questions and perform actions. "
                        "Keep in mind that prompts are transcriptions of user speech and they may "
                        "contain misspellings or errors. Try and interpret them as best as possible. "
                        "When possible, keep your answers short and concise."
                    ),
                }
            ],
        )
    
        # If the response is not empty, render it using the TTS plugin
        if response:
            event.assistant.render_response(response)
    

    When a response from the LLM ends with a question mark, the assistant will automatically listen for a follow-up command and fire a new SpeechRecognizedEvent.

    Pausing music while listening

    One nice touch is to pause the music when a conversation starts and resume it after the assistant is done.

    from platypush import run, when
    from platypush.events.assistant import (
        ConversationEndEvent,
        ConversationStartEvent,
    )
    
    
    @when(ConversationStartEvent)
    def on_conversation_start(event):
        run("music.mpd.pause_if_playing")
    
    
    @when(ConversationEndEvent)
    def on_conversation_end():
        run(
            "utils.set_timeout",
            name="ConversationEndTimeout",
            seconds=5,
            actions=[{"action": "music.mpd.play_if_paused"}],
        )
    

    That makes the interaction feel much less clumsy: wake word, music ducks or pauses, command is recognized, answer is spoken, music resumes a few seconds later.

    Going fully local

    With the configuration above, hotword detection, speech-to-text, automation and text-to-speech are already local. The only non-local component is the openai plugin, if it points to OpenAI's servers.

    To make the last step local too, run a model server that exposes an OpenAI-compatible API. Ollama, llama.cpp server, vLLM and LocalAI can all expose some version of /v1/chat/completions.

    For example, with Ollama:

    ollama pull llama3.1:8b
    ollama serve
    

    The OpenAI-compatible endpoint is then usually available at:

    http://127.0.0.1:11434/v1/chat/completions
    

    If your Platypush openai plugin version supports a custom API base URL, the configuration is the whole change:

    openai:
      model: llama3.1:8b
      base_url: http://127.0.0.1:11434/v1
    

    If it does not, keep the rest of the assistant exactly the same and replace only the fallback action with a tiny local request:

    That is enough to turn the assistant into a fully local stack:

    [diagram 2]

    On a Raspberry Pi, I would still keep expectations realistic. Hotword detection, Vosk and Piper are fine on small machines. Local LLMs are the heavy piece. A Pi 5 with enough RAM can run small quantized models, but latency will not feel like a cloud model or a GPU-backed workstation. For many home automation workflows, that is acceptable because the LLM is only the fallback; the frequent commands stay deterministic.

    Why this architecture ages well

    Voice assistants have been a graveyard of abandoned SDKs and cloud products. Snowboy is gone. Mycroft is gone. The old Google Assistant SDK is deprecated. Vendor assistants are increasingly shaped around vendor ecosystems rather than user-controlled automation.

    The safer long-term bet is not one monolithic assistant. It is a pipeline of small replaceable parts:

    • Swap the hotword model without touching the automation logic.
    • Swap Vosk for another STT engine without touching Hue or MPD.
    • Swap OpenAI for a local OpenAI-compatible model without touching the wake word, TTS or command hooks.
    • Swap Piper voices without touching the assistant flow.

    Platypush is a good fit for this because its event system is already the boundary between perception and action. Speech recognition emits an event. Hooks decide what to do. Plugins execute the actions.

    That separation is what makes the assistant inspectable. It is also what makes it possible to keep most of it on a Raspberry Pi in your house, instead of outsourcing the entire audio loop to a cloud service that may disappear, get worse, or decide one day that your use case is no longer part of the roadmap.

    Final notes

    The minimal version of this setup is small:

    • assistant.openwakeword for the always-on wake word.
    • assistant.vosk for local command transcription.
    • A few @when(SpeechRecognizedEvent, phrase=...) hooks for deterministic commands.
    • light.hue, music.mpd or any other Platypush plugin for actions.
    • tts.piper for local spoken responses.
    • openai.get_response only where language understanding is worth the cost.

    Start with the deterministic commands. Add the model fallback later. That way the assistant stays fast for the commands you use every day, while still being flexible enough to answer questions or interpret messy speech when you need it.

  5. #ActivityPub support in #Madblog

    https://blog.fabiomanganiello.com/article/Madblog-federated-blogging-from-markdown

    I am glad to announce that Madblog has now officially joined the #Fediverse family.

    If you want to test it out, search for this URL on your Fediverse client.

    Madblog has already supported #Webmentions for the past couple of weeks, allowing your blog posts to be mentioned by other sites with Webmentions support (WordPress, Lemmy, HackerNews…) and get those mentions directly rendered on your page.

    It now adds ActivityPub support too, using #Pubby, another little Python library that I’ve put together myself (just like Webmentions) as a mean to quickly plug ActivityPub support to any Python Web app.

    Webmentions and Pubby follow similar principles and implement a similar API, and you can easily use them to add federation support to your existing Web applications - a single bind_webmentions or bind_activitypub call to your existing Flask/FastAPI/Tornado application should suffice for most of the cases.

    Madblog may have now become the easiest way to publish a federated blog - and perhaps the only way that doesn’t require a database, everything is based on plain Markdown files.

    If you have a registered domain and a certificate, then hosting your federated blog is now just a matter of:

    mkdir -p ~/madblog/markdown
    cat <<EOF > ~/madblog/markdown/hello-world.md
    # My first post
    
    This is my first post on [Madblog](https://git.fabiomanganiello.com/madblog)!
    EOF
    
    docker run -it \
      -p 8000:8000 \
      -v "$HOME/madblog:/data" \
      quay.io/blacklight/madblog

    And Markdown files can be hosted wherever you like - a Git folder, an Obsidian Vault, a Nextcloud Notes installation, a folder on your phone synchronized over SyncThing…

    Federation support is also at a quite advanced state compared to e.g. #WriteFreely. It currently supports:

    • Interactions rendered on the articles: if you like, boost, quote or reply to an article, all interactions are rendered directly at the bottom of the article (interactions with WriteFreely through federated accounts were kind of lost in the void instead)

    • Guestbook support (optional): mentions to the federated Madblog handle that are not in response to articles are now rendered on a separate /guestbook route

    • Email notifications: all interactions can have email notifications

    • Support for quotes, also on Mastodon

    • Support for mentions, just drop a @[email protected] in your Markdown file and Joe will get a notification

    • Support for hashtag federation

    • Support for split-domain configurations, you can host your blog on blog.example.com but have a Fediverse handle like @[email protected]. Search by direct post URL on Mastodon will work with both cases

    • Support for custom profile fields, all rendered on Mastodon, with verification support

    • Support for moderation, either through blocklist or allowlist, with support for rules on handles/usernames, URLs, domains or regular expressions

    • A partial (but comprehensive for the provided features) implementation of the Mastodon API

    If you want you can follow both the profiles of my blogs - they are now both federated:

    • My personal blog: @fabio (it used to run WriteFreely before, so if you followed it you may need to unfollow it and re-follow it)

    • The #Platypush blog: @blog

  6. 📰 New blog article

    Self-host your own multi-service #music server on #Android

    How to replace your music streaming apps with a setup that supports multiple streaming services, multiple devices and multiple outputs from a single Webapp.

    #mopidy #platypush #termux #ntfy #Tasker #python

    @Selfhosted @Android @python

    https://blog.platypush.tech/article/Self-host-your-music-experience-on-mobile

  7. 📰 New blog article

    A self-hosted solution to create unlimited private email aliases. Featuring:

    • #Postfix (mail server)
    • #ntfy (pub/sub over HTTP)
    • #Platypush (to listen to alias requests, create them and notify the clients)
    • #Tasker (to conveniently wrap the service on Android into a simple app)
  8. With the release of Trixie, the CI/CD pipeline of #Platypush has started releasing Debian packages for the dev branch that target either Trixie:

    # /etc/apt/sources.list.d/platypush.list
    deb https://apt.platypush.tech/ stable dev

    Or Bookworm:

    # /etc/apt/sources.list.d/platypush.list
    deb https://apt.platypush.tech/ oldstable dev

    Which means that if you use an older version an apt upgrade will probably break your Platypush installation.

    At that’s indeed been the case for me, since I still run a bunch of RaspberryPis on older versions of Debian that I’m too lazy to upgrade.

    I don’t feel like creating new time/resource-consuming CI/CD processes to target each of the past versions of Debian and Ubuntu, but if you use an old version you should be able to make Platypush work after the installation by simply creating a symbolic link:

    # If you configured the oldstable repo on Bullseye
    # Otherwise change the target directory with the one that matches your Python version
    sudo ln -s /usr/local/lib/python3.11/dist-packages/platypush /usr/local/lib/python3.9/dist-packages

    This is guaranteed to work as long as you use a version of Python released any time in the past decade. I strive to always keep Platypush compatible with Python >= 3.6, and with any versions of the dependencies released in that timeframe, and most of the core logic compatible even with Python 3.5 - even if that means keeping a lot of extra logic to check libraries versions, or avoiding all constructs introduced in recent versions of the language.

  9. 📰 New blog article

    I’ve recently put together an improved version of my #Platypush + #Wallabag + #MercuryReader + #InternetArchive solution to scrape and archive articles.

    So far it seems to work with nearly any major news website and blogging platform that I regularly read, so it’s the most likely candidate to replace solutions like 12ft / 13ft that have been broken for a while, at least for me (maybe I could put all these scripts together and call it 14ft?)

    I don’t support piracy when it harms creators, but if buying ain’t owning then piracy ain’t stealing. But always remember to support your creators through other means.

    https://blog.platypush.tech/article/Building-a-better-digital-reading-experience

  10. 📦 I have just released the version 0.2.0 of the #Platypush web extension - which hadn’t seen any commits since 2020, and too much pending work was long overdue.

    The changes:

    • The extension builds again with modern node dependencies. Some of the dependencies had rotten (for example a version of node-gyp was used that still relied on python2) and fixing the build was the first priority.

    • Simpler configuration. The configuration form was very out-of-date, and it still required many settings that are no longer required in Platypush. It’s now possible to add Platypush services by simply providing the URL, and log in via token or browser flow.

    • Better README

    • Removed Chromium support. Sorry, no MV2, no party. I want the ability to run custom JS from strings through the executeScript API, so users have a GreaseMonkey-like API to interact with their browser and also access to the Platypush APIs. I want the ability to perform custom HTTP requests in my extension so I can connect to multiple Platypush devices without having to go crazy with the declarative approach. MV3 was a disgrace, I don’t have enough time to invest to complicate my life just to support a mutilated version of this extension on Chromium, and if it means that the Platypush extension will only target <5% of the browser market then let it be.

    Btw, this partial rewrite of the extension was motivated by some tinkering I’ve been doing lately to save web pages to Wallabag also when I go through the Web Archive (which has added Cloudflare gateways that prevent the Wallabag scrapers from saving archived pages). Since my flow to save web pages is managed through a script in the Platypush extension, I’ve modified it to scrape the DOM on archive.is on the client side instead, save the scraped HTML on a temporary web server, and let Wallabag save that URL instead (so no CAPTCHAs and no Cloudflare).

    The set up is still a bit messy at the current stage, but after refactoring a bit I may write a new article on how to replicate it.

  11. It looks like runtime context evaluation in YAML procedures/hooks in #Platypush is broken as of #Python 3.13 - reference issue.

    I’ve noticed it today after upgrading one of my nodes to Python 3.13, and its hooks suddenly stopped working.

    As of Python 3.13, it’s no longer allowed to set local variables via locals(), or exec()/eval(), so the following code breaks on the latest version:

    >>> def f():
    ...     exec('foo="bar"')
    ...     print(foo)
    ...
    >>> f()
    Traceback (most recent call last):
      File "<python-input-3>", line 1, in <module>
        f()
        ~^^
      File "<python-input-2>", line 3, in f
        print(foo)
              ^^^
    NameError: name 'foo' is not defined

    Whereas it works on previous versions:

    >>> def f():
    ...     exec('foo="bar"')
    ...     print(foo)
    ...
    >>> f()
    bar

    I know that manually setting local variables by assigning keys to locals() or via exec/eval isn’t pretty, it’s a quite nasty abuse of a side effect, and it makes the code harder to debug.

    But there are legit cases too. For example, Platypush allows to define event hooks in YAML this way:

    event.hook.LogLatLongUpdate:
        if:
            type: platypush.message.event.geo.LatLongUpdateEvent
        then:
            - action: procedure.store_location_data
              args:
                  latitude: ${latitude}
                  longitude: ${longitude}
                  altitude: ${altitude}

    Where latitude, longitude and altitude, in this case, are parsed from the incoming event, assigned at runtime to local variables, and then the execution flow can just expand them on the fly.

    Apparently this flow is no longer allowed.

    I’ll investigate for workarounds (globals() doesn’t seem to be impacted, but I definitely don’t want to mess up with global variables), because at the current stage all those occurrences need to be replaced with e.g. ${context["event"].latitude} - still ok-ish, but definitely less readable.

    I’m also a bit disappointed that such a breaking change wasn’t properly announced - sure, people shouldn’t mess up with the local variables of the interpreter at runtime, but there are legit cases that were worth discussing, and alternatives could have been provided, rather than seeing some code break from a day to another and getting a “sorry, you were never supposed to do this” as an answer.

  12. 📦 #Platypush 1.3.5 is out!

    The main feature of this release is the support for multiple backends in the youtube plugin.

    It allows you to watch ad-free YouTube videos on any supported media player and manage your playlists and subscriptions through multiple YouTube implementations.

    Support for multiple YouTube backends

    Earlier only #Piped was supported, but given the state of the project and most instances (all the ones I’ve tested, including my own, are still blocked by #YouTube’s new restrictions) I’ve added support for #Invidious too, and that’s currently the recommended backend.

    The in-browser YouTube player now plays videos using the Invidious embedded player if you configure the invidious backend, so the UI can be used as a full alternative frontend for Invidious.

    I’ve also added a new google backend that leverages the official YouTube Data API to search and fetch your playlists and subscriptions, but keep in mind that:

    • It requires you to register a project on the Google Cloud developers console.

    • It doesn’t support the get_feed() action (YouTube has removed the endpoint in v3), so you won’t be able to get the latest videos published by your subscribed channel.

    • All searches and activities will be logged on your Google account, so it’s probably not the best option if you are looking for a privacy-aware experience (but video streaming will still be ad-free).

    State of YouTube media support

    The youtube plugin should work in tandem with any supported Platypush media integrations (tested with media.mpv, media.vlc, media.gstreamer, media.kodi and media.chromecast), but media.mpv is recommended. The reason is that mpv provides the --ytdl option out of the box to leverage yt-dlp to download and stream videos on the fly, while other media plugins will have to first download the full video locally before streaming it (I’ve tried to implement my own real-time media streaming server, but I’m still not very happy with its stability).

    Leveraging the support for multiple backends to migrate your data around

    I’ve always been baffled by the fact that there isn’t a standard format to export playlists/subscriptions across different backend implementations (even among alternative backends, such as Piped and Invidious).

    As someone who has migrated through different YouTube backends and apps depending on the state of restrictions implemented by Google, I’ve often had to write my own scripts to convert CSV/JSON exports from one platform or app to a format understood by the new solution.

    Since the Platypush youtube plugin exposes the same API regardless of the backend, it is possible to configure multiple backends, and write a small script that fetches all playlists and subscriptions from one and imports them into another:

    from platypush import run
    
    # Get all the playlists from e.g. the Piped backend
    piped_playlists = run("youtube.get_playlists", backend="piped")
    piped_playlists_with_videos = {
      pl["id"]: {
        item["id"]
        for item in run(
          "youtube.get_playlist",
          id=pl["id"],
          backend="piped"
        )
      }
      for pl in piped_playlists
    }
    
    # Create the playlists on Invidious and populate them
    for pl in piped_playlists:
      invidious_playlist = run(
        "youtube.create_playlist",
        name=pl["name"],
        backend="invidious"
      )
    
      run(
        "youtube.add_to_playlist",
        playlist_id=invidious_playlist["id"],
        item_ids=piped_playlists_with_videos[pl["id"]] or [],
        backend="invidious"
      )

    Note that the simple script above doesn’t handle merge of existing playlists and items, but it can be easily adapted - if there’s enough interest I may write a small blog article with a more complete example.

    Other release features

    The full changelog of the new release is here. Besides the youtube integration changes, this release includes the following features:

    • Many stability/performance improvements for the music.mopidy integration - especially in handling connection recoveries.

    • Support for ungrouped lights in the light.hue plugin.

    • Added a new Application tab to the UI, which allows you to monitor all events and requests handled by the service.

    • Adapted ssl layer to Python 3.12 (which has deprecated ssl.wrap_socket()).

    • Migrated the kafka integration to kafka-python-ng instead of kafka, which is currently broken and basically unmaintained.

  13. @Nelfan I use #NewPipe on Android, but unfortunately it doesn’t come with a web version.

    After self-hosting #Piped for a while I’ve recently switched to #Indivious (Piped isn’t seeing much development and it’s much easier to get blocked by YouTube by using it), and I must say that, hosted on a residential address and with IPv6 rotation, it does its job quite well.

    For everything else (streaming on TV, Chromecast etc.) #Platypush with the YouTube plugin and MPV/VLC does a very good job, as long as yt-dlp is up-to-date (of course, being the main developer of it I’m a bit biased here).

    I really hope that yt-dlp keeps working, and I’d direct my efforts towards keeping that alive, because yt-dlp functioning properly (and not only for YouTube) means that a lot of projects downstream will keep functioning.

  14. @77nn I’m still quite passionate about IoT, and being the main developer of #Platypush I still try my best to have a fully on-device automation experience.

    There are many applications where automation actually makes life easier (like turning off all lights and appliances automatically when you exit your home, or turning lights on/off depending on the luminosity in the room or when motion is detected, or setting the temperature of your house dynamically, or monitoring the consumption of my washing machine/smart tv, not to mention anything related to media center events).

    Voice commands are also not always required if you set up a bunch of smart buttons that communicate over standard protocols, or even a bunch of IR receivers for traditional remotes. But sure voice interaction is still the weak point in the chain - things got better than a couple of years ago, and it’s now become more feasible to run on-device voice models also for devices that don’t have bleeding edge specs, but there still a lot of progress to be made. I still piggyback on the (deprecated) Google Assistant SDK for most of my voice needs on most of my RPis.

    TL;DR: things are slowly getting better, and I think that using a local MQTT broker, a gateway like zigbee2mqtt or zwavejs2mqtt, maybe a bunch of custom devices with Arduino or ESP, and a local automation platform like HomeAssistant, OpenHUB or Platypush to tie things together now you can get a decent local home automation experience even without being connected to the Internet. But there’s still some progress to do, and sure the learning curve is still steeper than the alternative “buy our bridge and download our app” approach.

  15. It looks like streaming #YouTube videos in #Platypush is currently broken because of new checks put in place by #Google.

    #mpv with --ytdl is also affected, and so are static downloads through the yt-dlp command of course.

    It seems that providing an API token to the yt-dlp configuration may mitigate the issue, but of course you lose anonimity.

    I’ll test things with an auth token and document the new process this week.

    https://github.com/yt-dlp/yt-dlp/issues/10128

  16. 📦 #Platypush 1.3.0 is out!

    This release turns procedures into first-class citizens.

    Procedures are the bread-and-butter of Platypush customizations. They allow you to specify some custom logic that can run within the application, either from structured requests or code snippets - both #Python and #YAML scripts are supported. They are akin to recipes in #IFTTT and tasks in #Tasker.

    You can call them from an event hook when a certain condition is met, from a cronjob or an alarm, from stand-alone scripts, from other procedures, and so on.

    This release improves the integration of procedures into the UI, turning them into entities that can be controlled from the entities panel - and, soon, embedded into custom dashboards.

    It also introduces a new powerful procedure editor, which allows you to visually create your automation routines through an intuitive UI with drag-and-drop support (a big part of this release has been about getting drag-and-drop to work nicely on mobile too). The new UI supports nested if/for/while blocks, break/continue/return, setting context variables, variable name autocompletion, export to YAML (if you prefer to have your procedures stored in the configuration rather than the db), and more.

    This UI has been inspired by the job done by Joao Dias on Tasker (I’ve always wanted to have a similarly powerful block-based UI to create custom routines also on desktop/server), and in part by IFTTT’s recipe editor.

    Python procedures can also be easily managed through a new file editor component that allows you to precisely navigate to their definition.

    This release also includes a new file browser component. You can now browse your files on the Platypush instance, create/edit/delete/upload/download them directly from the Platypush UI, even if you don’t have SSH access to the machine.

    Happy hacking!

  17. Adding #YouTube videos to #Piped playlists seems to be broken now (or at least it only works sometimes).

    It seems that even videos metadata (besides the actual media) now can’t be fetched via the YouTube API if YouTube decides for whatever reason to throttle your IP.

    Which means that the youtube integration in #Platypush will probably have to implement its own backend to save playlists and subscriptions instead of piggybacking on Piped.

    Switching the backend to #Invidious probably won’t help either, as most of the Invidious instances are now broken too.

    At this point it almost looks like #Google is going down with its aggressive policy against 3rd-party YouTube clients to the point that they’re ok to even break their own APIs.

    The only working solutions that I currently have to watch YouTube videos without going through their app/site are:

    • Platypush’ YouTube integration paired with a media plugin (like vlc, mpv or gstreamer) or Kodi/a Chromecast-compatible device. Platypush under the hood uses yt-dlp to do the magic, so if you run a Platypush instance in your home network your IP will still be visible to Google, but it’s still better than the alternative (watch endless ads or access their app/site while logged in with your Google account). Support for user playlists and subscriptions is currently buggy though, since it relies on the Piped API, and basically all Piped instances are currently broken.

    • Firefox’ Open With extension paired with #mpv, which comes with native yt-dlp support. That extension is unmaintained, and it doesn’t support ways to automatically open URLs with an external app (you have to explicitly open the URL with the extension), but so far it’s the only extension that I’ve managed to get to work with an external player - I haven’t had much luck with the External Application Launcher yet, which in theory should also support auto-opening specific URLs with the external app rather than the browser.

    No solutions on mobile yet, but I’m working on empowering the streaming capabilities in Platypush so a “Play in browser” option for YouTube videos can be a thing.

  18. I have a little gift for all the frontend folks out there who have struggled to get drag-and-drop interfaces to work both on desktop and mobile.

    The source of all problems is that touch-based browsers usually don’t listen to higher-level drag/drop events like pointer-based interfaces. A long click on a component usually means that I want to drag that component. That doesn’t apply however to touch-based interfaces, where I may just want to drag my thumb to scroll, zoom, pinch or do any kind of touch gesture.

    I’ve spent the past couple of days working on a UI component for #Platypush that allows users to modify procedures by dragging actions around, rendered as tiles. I want the dragging experience to feel the same both on desktop and phone. But I don’t want the touch-based experience to turn into a dragging hell where simply scrolling over the container results in blocks being randomly dragged around. Ideally, I’d like it if #JavaScript on mobile natively implemented a long-ish press on a draggable component to enter the dragging state, while a touchstart event followed by touchmove all over the screen usually means that I’m scrolling or doing something else.

    Since such API isn’t there, and I couldn’t find much around either, I’ve decided put together two #Vue components, Draggable and Droppable, that do exactly that.

    They can be imported anywhere in your template, linked to a DOM element that you want to be able to drag or accept dragged elements respectively, and they’ll install all the right event handlers and classes and proxy the events in order to expose a consistent API both on desktop and mobile.

    They’ll also take care of little details to make the experience consistent across devices, such as scrolling the nearest scrollable parent if you drag a component above or below its container, or generating a thumbnail version of the dragged component that will be displayed while the cursor moves around.

    By default on mobile it will initiate the dragging state only if the draggable component is touched for more than half a second, but you can customize this behaviour by setting the touch-drag-start-threshold property on the Draggable component - a value of 0 will start dragging as soon as you touch a draggable item.

    The commit message is quite self-explanatory, and it also shows how to customize the style of the dragged/active/selected components.

    The commit is quite self-contained and the two files come with no dependencies, so you can literally drop them in a Vue project and import them. But if there’s sufficient interest I could put together an #npm module for this.

    Now that the (technical) UI problem is mostly solved, I still see a UX issue: how to make it clear to mobile users that a certain component is draggable? It’s usually quite straightforward on desktop - turn the cursor icon into a dragging hand or moveable arrows. But folks just aren’t used to drag-and-drop experiences on mobile (outside of native apps), and unless they accidentally stumble upon it while scrolling around they may never know that an interface supports it. Outside of invasive help popups and UI tours that take time and screen space, would there be some obvious way (like an icon or some other kind of decoration) to visually annotate a component that can be dragged, both on mobile and desktop?

    @programming

    https://git.platypush.tech/platypush/platypush/commit/1316af9553cdbcb619b4433ba50eea3911fd3ea8

  19. If implementing a properly working drag and drop interface in #JavaScript wasn’t already hard enough, imagine realizing that drag events aren’t usually implemented on mobile interfaces, and that you have to reinvent the whole dragging logic on top of the basic touchstart/touchmove/touchend events.

    I’m currently implementing a portable drag-and-drop logic in #Platypush as reusable components, so you have an API like this:

    <template>
      <div ref="draggable">...</div>
      <div ref="droppable">...</div>
    
      <Draggable :element="$refs.draggable" @drag="onDrag" @drop="onDrop" />
      <Droppable :element="$refs.droppable" @drop="onDrop" @dragover="onDragOver" />
    </template>

    The Draggable and Droppable components will then be in charge of registering the right listener and properties on the specified DOM elements, and do it in such a way that works both with the drag* and touch* APIs.

    Something tells me that this could be a quite common problem, so odds are that I may release this as a tiny separate npm module if there’s enough interest - not sure how common are the use-cases of drag-and-drop Web interfaces that need to work both on desktop and touchscreens.

    @programming

  20. @kuketzblog you may want to check the latest updates on #Platypush: https://blog.platypush.tech/article/Play-all-media-everywhere.

    The latest version basically takes care of the transcoding and streaming of content from yt-dlp too, it's already compatible with the Piped API (so you can easily access your playlists and subscriptions there too, by enabling the YouTube plugin), and it supports a wide range of players - VLC, MPV, mplayer, gstreamer, Kodi and Chromecasts.
  21. 📦 #Platypush 1.2.3 is out!

    The main focus of this release is on the #media side.

    In particular, Platypush now supports streaming/playing/downloading any media compatible with youtube-dl / yt-dlp, even if the upstream audio/video files are split - yay!

    This means that it’s again compatible with #YouTube URLs (the integration broke recently after YouTube migrated all of its media to split video+audio tracks), and a lot of other sources that have been using this practice for a while - Facebook, Instagram, X, TikTok etc.

    It means that you can play anything that yt-dlp can digest to any supported media plugin - VLC, mpv, mplayer, gstreamer, Kodi or Chromecast/Miracast.

    Note however that mileage may vary depending on the player.

    Things work fine out of the box if you use media.mpv. MPV comes with native youtube-dl support, and the right stuff will be used to play the video smoothly if youtube-dl or yt-dlp are present on the system.

    media.vlc and media.gstreamer now provide two different play modes for YouTube-compatible content: play on the fly and play with cache. In play-on-the-fly mode (default) audio and video content will be mixed on the fly over ffmpeg and piped to the player process. This means shorter load times, it’s a good fit for live streams and large files, but it also means potentially lower media quality, high chances of media jitters in case of gaps in the stream being transcoded, and reduced ability to seek through the media. In play-with-cache mode the transcoded content will be cached to disk instead. It means waiting a bit longer for the video to load, and higher disk usage in case of large streams, but also a more robust and smooth playback experience.

    However I’m investigating a way to pass both an audio and a video URLs to GStreamer (it doesn’t seem to be easily feasible with VLC), so the player can do its own tuned mixed playback without me having to reinvent the wheel. If I can sort it out, and manage to avoid big audio offsets in the playback process, then this could be the default mode for GStreamer.

    media.mplayer only supports play-with-cache mode. The plugin already uses the player’s stdin to communicate commands, and AFAIK MPlayer doesn’t support sending both commands and media bytes to the player. Same goes for media.kodi.

    media.chromecast mileage may vary depending on the model of Chromecast. I haven’t had much luck playing audio+video simultaneously when Platypush streams YouTube content to 1st-gen Chromecasts because the new video codecs used by YouTube videos apparently aren’t available on those devices. I’ve had mixed results by forcing the container to transcode the video track to H264 (and that is also the new default configuration for ytdl_args for the media.chromecast integration), but there’s still a 50/50 chance that the Chromecast will only play the audio. I’ve had better luck with more recent Chromecast models though. And I believe that things should work just fine if you use any modern Miracast/DLNA-compatible device/dongle. Given the deprecation status of the Chromecast, and the dubious compatibility with whatever the Google TV folks are planning next, I’m not even sure if it’s worth investing further energies in for the Chromecast compatibility. media.chromecast now also provides a use_ytdl configuration flag - it’s set to true by default, but you can disable if you want to stream YouTube/Facebook/TikTok etc. URLs to your Chromecast bypassing the Platypush streaming service. This means higher chances that the content will play fine, but it also means that it’ll be played by whatever compatible app (if available) runs on your Chromecast (i.e. ads/tracking/account limitations/geo limitations etc.).

    https://blog.platypush.tech/article/Play-all-media-everywhere

  22. #YouTube playback is now broken on most of the #Piped instances out there, included mine. Google has decided to get tighter on IP checks and most of the videos are now replaced by a “Please sign in to confirm that you’re not a bot” text.

    In the meantime, YouTube playback over external media plugins is currently also broken on #Platypush. YouTube has apparently completed the migration of all of its media to split audio+video tracks to be transcoded on the client, so the old trick of getting the audio+video direct URL via yt-dlp and passing it to vlc/mpv/Chromecast no longer works - you’ll mostly likely get an audio-only or video-only track.

    A new version of Platypush that fixes the issue is on the way, but it comes with a trade-off, which is now embodied by the new cache_streams media plugin setting:

    1. cache_stream=false (default) means that transcoding and streaming will occur in memory. This means less clutter on disk and playback that can start almost immediately. It also means however that the media quality may experience temporary jitters while it downloads, especially at the beginning. And it also means that seeking, depending on the player, may not (always) be available. It also results in higher memory usage for large files, although usually ffmpeg takes care of buffered memory quite efficiently.

    2. cache_stream=true means that the transcoded media file will be temporarily cached on disk. This usually results in better media quality and robust support for seeking within the file. It also means however higher disk usage, especially if you’re watching large videos or live streams. And it also means a longer waiting time before playback, as at least one of the part files must be fully downloaded before streaming can start.

    With such changes however media playback (with audio+video) via external player is now supported in Platypush also with other yt-dlp compatible sources (such as Facebook, Instagram, Twitter and TikTok URLs) that already had separate video+audio tracks.

    Stay tuned for the new release!

  23. Anyone knows of something like #ffplay, but with support for external commands to control playback?

    Maintaining four different local media integrations in #Platypush (vlc, mpv, mplayer and omxplayer) is tiring, and each of those players comes with its own overhead, caveats and API quirks.

    I’d be much easier if I could just pipe any stream to ffplay - and call it a day, and have a way to easily pass play/pause/stop/volume etc. commands to ffplay - over stdin, over socket, over signals, anything works.

    Platypush could then be “its own” media player solely based on ffmpeg. Anything would be piped to ffplay over stdin.

    Unfortunately, I can’t find a single way to programmatically control ffplay during playback that works - outside of hacks with keystroke emulation that are unlikely to work in Wayland anyway.

    And so far I’ve been very tempted from meddling with GStreamer unless really required - first because it’d be a Linux-only solution, and second because it depends on the dbus+GLib and carries a whole lot of dependencies with it, while ffplay needs basically only the ffmpeg package.

    Anyone who knows how to get this to work, or even a simple stand-alone command line player or media framework that can be externally controlled and uses as a base, feel free to share!

  24. 📦 #Platypush 1.2.1 is out!

    There’s a large changelog, in a nutshell:

    • 2FA support is here.

    • Added randomly generated API tokens alongside JWT tokens.

    • Added bind_socket option to backend.http - now the Webapp can also be exposed on a UNIX socket rather than TCP.

    • Support for per-plugin PWAs. You can now open <platypush-host>/plugin/<plugin-name> from your browser on mobile, and install the PWA associated only to that plugin view. This is similar to what NextCloud offers - if you install the PWA from the calendar page it’ll install only the calendar app, if you install it from the notes page it’ll only install the notes app, and so on.

    • Added support for custom Redis executables on --start-redis via --redis-bin - now tested also with alternative Redis implementations, namely Valkey and Redict.

    • Fullscreen video/photo support for the camera views.

    • Migrated the project from setup.py to pyproject.toml (it was about time).

    The backlog is already filling up for the next release.

    As usual, feel free to report your suggestions and feature requests on the issues page.

  25. #Platypush + #WebPush loading

    I’ve taken quite a deep dive in these days into the WebPush implementation and the details of the #VAPID specification for authenticated push notifications through #PWA.

    I’m now testing things in the Platypush web app - aiming to support custom notifications providers too, so dispatching notifications to your mobile devices through your ntfy or NextPush server can also be supported.

    The next release of Platypush may finally include native Web notifications through the PWA layer.

    You should then be able to get notifications for all the media playing on your devices, and control them just like you would do with a Spotify/YouTube media notification, without having to use intermediary layers such as Tasker, Termux, Pushbullet or ntfy. Or get mobile notifications for the interactions with your custom voice assistants. Or create your custom push notifications (e.g. on your event hooks, custom procedures or crons) and dispatch them securely to your mobile devices through your own ntfy server. All (hopefully) without the need of a native Android app - power of Web pushes!

    I only wish that the tools to implement WebPush/VAPID in #Python applications were as mature as those available for the JS ecosystem. py-vapid seems reasonably well designed, but it’s still a bit of an early project and it’s not even available on any major package managers (which is a big no-no for core Platypush features). And it only takes care of signing VAPID claims, not of packing and delivering WebPush requests end-to-end. I’ve eventually resorted to doing my own implementation with ecdsa, plus jose to take care of the JWT encryption boilerplate. I may write a little blog article if it ends up working.

    https://git.platypush.tech/platypush/platypush/issues/417

  26. #Platypush 1.1.3 is out 📦

    The main focus of this release is on the #YouTube integration and the media UI.

    A new built-in download manager is now available for all the media plugins.

    The main beneficiary is the youtube integration, which now supports local media downloads (and audio-only downloads) for any YouTube content - and any other URL compatible with yt-dlp, e.g. Facebook, TikTok, Twitter etc.

    The YouTube integration also got more #Piped features, and it can now be used a full alternative Piped UI, including:

    • Playlists support - search playlists, create/remove and add/remove items and navigate playlists directly from search results/library items.
    • Channels support - search channels, subscribe/unsubscribe and navigate channels from search results/library items.

    Add the existing media integrations features on top, and you have a system where you can easily download almost any URL from the Web, or play it on any compatible player - vlc/mpv/mplayer, Chromecasts, Kodi etc.

    The release also includes a new architecture for the core Redis messaging system - now using a connection pool and a pub/sub mechanism instead of a static queue, which makes the service much faster and greatly reduces the chances of Redis deadlocks.

    Happy hacking!

  27. 📢 New #blog article

    Buiding custom voice assistants, 2024 edition

    This is the third article I write on #voice #technology in #Platypush. I wrote the latest one in 2020, but unfortunately all the voice products I described there (Snowboy, DeepSpeech, Mycroft, AVS…) are now gone.

    assistant.google is still there (but I don’t know for how long, as the underlying Assistant library was deprecated back in 2019), but Platypush 1.0 has also added two more integrations to the mix:

    • assistant.openai
    • assistant.picovoice

    These are based on the OpenAI voice+GPT APIs and the Picovoice products respectively, and can be combined to provide a very modern and flexible voice assistant interface.

    The article also describes how to create your custom action hooks to control your devices and services, train assistants with context, customize voice models, play custom conversation sounds, handle voice intents and offline transcriptions, and run multiple assistants on the same device.

    Happy read!

    https://blog.platypush.tech/article/The-state-of-voice-assistant-integrations-in-2024

  28. #Platypush 1.0 is out!

    It’s been 10 months and 1049 commits since the latest release of Platypush, 7 years since the first commit, and 10 years since the first release of its ancestor, evesp.

    The past few months have been quite hectic and I have nearly rewritten the whole codebase, but I feel like the software is now at a stage where it’s mature and stable enough to be used by a larger audience.

    The changelog is quite big, but it doesn’t even cover all the changes, as many integrations have been completely rewritten.

    The biggest (breaking) change is the merge between plugins and backends. Now, except for those integrations that actually listen for messages and execute them (like HTTP and Redis), all the other integrations are plugins. This greatly simplifies the configuration and removes a lot of confusion for new users.

    The Docker support has been greatly improved too. There are now officially supported multi-arch images for Alpine, Debian, Ubuntu and Fedora, an official docker-compose.yml file, and both the platydock and platyvenv utilities have been almost completely rewritten to seamlessly automate the creation and configuration of containers and virtual environments (respectively) starting from a single config.yaml.

    And the Python API has become much simpler and consistent. No more __init__.py files that the user had to manually create in each subfolder of scripts, just drop a .py file with your automation in the scripts dir and it’ll be picked up. Moreover, the most common imports are now available on top level as well, and there’s no more need to create procedures/hooks/crons with varargs:

    from platypush import run, when
    from platypush.events.sun import SunsetEvent
    
    @when(SunsetEvent)
    def sunset_lights_on():
      run('light.hue.on')

    There’s also a revamped documentation portal, which now includes both the wiki and the plugin reference.

    Most of the integrations have been rewritten at different degrees, and in the process many bugs have been squashed, many features added and many APIs updated to be more consistent, so make sure to check the documentation pages of your integrations in order to migrate.

    And if you have more requests or questions, feel free to open a ticket, a PR or ask on the Lemmy server.

    https://blog.platypush.tech/article/Platypush-1.0-is-out

  29. @theendismeh #Platypush comes with several camera plugins. It works with any camera supported either by ffmpeg or gstreamer, and it has plugins also for PiCamera. If you choose this option I’d advise you to use the git version rather than the one on p PyPI - it’s a bit old and I’m planning a new release.

    If you use a RPi with a PiCamera then you can use RPi Camera Viewer, a free Android app. Otherwise, for any other USB camera with ffmpeg/gstreamer, you can either view the stream from the Platypush webapp, or open the URL in VLC or anything compatible (support for RTSP is on my backlog).

    I have 6 RPis with various cameras scattered around the house and I’ve never needed any other solution.

    I’m just unsure about the “easy for non techies” requirement - I mean, the integration can be set up from a simple YAML configuration, but even as I’m working to lower entry barriers, Platypush is still mainly geared towards DIY.

  30. Is anyone aware of ways to control #ffplay programmatically in any form, without having to focus the window and having to emulate keyboard/mouse bindings in it?

    ffplay is amazing, light, fast, and it’s a player that comes with any #ffmpeg installation.

    #Platypush supports media players such as VLC, mpv, mplayer, omxplayer and gstreamer, but they all come with their bags of issues - the VLC libraries seem to break too often on Wayland, mpv has too many API breaking changes across versions and controlling it only works if the version of the library and the player are carefully aligned, mplayer is an unmaintained dumpster fire with a messy control API, working with gstreamer in Python requires the user to install the whole fat GLib luggage and MBs of plugins, and omxplayer is basically dead.

    ffplay would be my favourite pick for a portable and lightweight default media player. But the fact that it apparently can’t be controlled in non-interactive ways really puzzles me.