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. 📢 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.

  3. 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:

  4. 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:

  5. 📢 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
  6. 📢 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
  7. 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.

  8. 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.

  9. #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

  10. #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

  11. 📰 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

  12. 📰 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

  13. 📰 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)
  14. 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.

  15. 📰 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

  16. 📦 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.

  17. 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.

  18. 📦 #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.

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

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

  21. 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

  22. 📦 #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!

  23. 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.

  24. 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

  25. 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

  26. @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.
  27. 📦 #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

  28. #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!

  29. 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!

  30. 📦 #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.

  31. #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

  32. #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!

  33. 📢 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

  34. #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

  35. #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

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

  37. 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.

  38. 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.

  39. Testing #Platypush with #Valkey and #Redict, now that #Redis has decided to apply a weirdly restrictive license like SSPL.

    At a first impression, it looks like Valkey is more ambitious and willing to implement many new features and optimize Redis' data model, while Redict seems to stick to "let's do what Redis already does best and become great at it, without time-series, open telemetry and a lot of new whistles and bells".

    I also wish that these projects will soon make it upstream in the major package managers. As of now most of the package managers still provide Redis, which isn't full FOSS anymore, and none of its recent forks.

    If you are working on a project that relies on Redis, what options are you currently considering after Redis' SSPL migration?
  40. Btw I’m wondering how this license may impact use-cases where the component A released under #SSPL is not used directly by software B, but software B uses C (or it forks C), which in turn uses A (i.e. if the license is transitive).

    An example: in #Platypush I use #Redis quite heavily - as an in-memory cache, as a pub/sub framework for inter-process communication, and as a memory queue.

    Platypush is already FOSS, but it’s released under a relatively permissive MIT license. (I’ve pondered a lot over the pros/cons of MIT vs. *GPL when licensing a product like Platypush, in the future I may also considering switching to AGPL, but for now MIT is a good trade-off).

    This means that people are free to copy the code of a Platypush plugin into their projects. Or use its plugins as libraries for their own integrations. Or extend it with their own plugins. Or make a fork and expose it as a service on their cloud. And the MIT license doesn’t require anybody to redistribute the full source.

    But Platypush also uses Redis, which is now under SSPL.

    What should company X, which has made their closed fork of Platypush with a bunch of proprietary integrations, and maybe distribute it as a service, do now? They are basically exposing a closed service that uses Redis under the hood, which violates SSPL. But the service itself is a fork of an open service released under MIT, so there’s no violation from that point of view.

    In other words, does SSPL override other more permissive licenses every time a product is exposed as a service?

  41. In 2024 I should try to learn and explore #platypush!

    Maybe learn some CI/CD? ... blog.platypush.tech/article/Se

    Maybe it will help me make an artsy Mastodon bot?

    Who knows...

  42. And there we go with my top tracks of the year too - powered by #Platypush and #SQL.

  43. A big change for #Platypush - and more are on their way before the next (very big) release.

    The #YouTube integration has been completely rewritten to remove all the references to the YouTube API. I've tried my best to play fair, but the YouTube API has seen way too many breaking changes recently, as a result of Google's strategy against scrapers and 3rd-party clients. I just can't keep maintaining an integration with an API provided by a company with such a hostile stance against developers.

    I want to spend my time making new things work, not fixing stuff purposefully broken by someone else. Even just searching for videos now requires a registered and approved Google project, and the user to be logged in: this isn't exactly the kind of stuff that is easy for anybody to set up and run.

    Also, scraping results from the Web interface is no longer possible unless the user has JS enabled - which means no more easy beautifulsoup scripts, one has to summon Selenium and its whole frontend suite to scrape stuff.

    From now on, the YouTube integration will use #Piped as a backend instead. A simple public API, subscribe to search results and feeds through simple RSS syndacation, and no more headaches with Google. This is what the developer experience with YouTube used to be until a few years ago, and how it should have remained.

    git.platypush.tech/platypush/p

  44. @futzle if you don't mind using software that may still be a bit in beta, you can give #Platypush a try.

    I wrote an article a while ago on how to set it up as a #Zigbee and #ZWave bridge: blog.platypush.tech/article/Tr

    Both the Zigbee and Z-Wave integration require an intermediate broker though - zigbee2mqtt and zwavejs respectively.

    Some changes and improvements are also coming up in the next release (I'm speeding them up a bit now that Hue has left a lot of orphans behind).

  45. There we go - the technological #enshittification pandemic has also reached Philips #Hue.

    Apparently they weren't making enough money by selling bulbs at $50/70 each. They'll now force you to log in through their app to the bridge too, or all of your bulbs will just stop working.

    What this means, among the other things, is that tons of unofficial integrations that have been built over the years (phue being one of them, which I contributed to in the past, and is also used by Platypush to interact with Hue bridges) are also likely to stop working once you upgrade your bridge's firmware. Those integrations leverage the old push-the-pairing-button mechanism to pair with the client, but now in-app authentication through a registered account seems to be a requirement - and I definitely have better things to do with my time than reverse engineer again their shitty authentication flow and push a PR to phue.

    Philips Hue (sorry, Signify B.V.; Philips has actually given up on building anything, they're just waiting for everybody who works there to retire) has joined the long wagon of companies that have realized that scooping up as much data as they can from their users (that probably includes at what time you usually wake up and go to sleep, from your bedroom lights patterns, or how often you go to the toilet) and selling it to data brokers provides a much steadier revenue stream than selling actual products that people want (even if those products are already quite pricey). And they don't care if fullfilling their new missions of being a mere data collector rather than a tech company means to literally break overnight the lights in the houses of millions of customers.

    Of course, I was kind of prepared for this. I have #Platypush installed on a RPi with a Zigbee dongle and zigbee2mqtt, and it already does the job for a bunch of Hue, Ikea and other cheap Zigbee lights. That's all you need to make your own Zigbee bridge. #HomeAssistant and #OpenHAB are other popular options.

    But it'll still take me a while to unpair a few tens of Hue devices in my house that are still connected to my Hue bridge (which I purchased a decade ago btw), and reconfigure tens of groups, scenes and automation routines on my self-managed bridge instead.

    I used to love being a software engineer, building things and solving problems. Now being an engineer sucks, even as a hobby, and I don't feel anymore like this is what I want to do with my life.

    It's not up to me to decide what to build anymore. It's up to Spotify killing their streaming libraries, Twitter or Reddit killing their API, Hue breaking their products if you don't log in through their app, YouTube coming up with ways to break youtube-dl on a daily basis, Google breaking your browser extensions, Red Hat and Docker turning suddenly hostile towards the FOSS community that made their fortunes, Messenger periodically logging out your alternative clients and locking your account, an increasing number of companies who insult the large community of unpaid volunteers that builds against their ecosystems as "free-riders" and make it their business mission to break their implementations, and the list could go on forever.

    I'm no longer working with ecosystems built by companies who genuinely want to build good things that people want to use, who treat the community of developers around them as an asset rather than a liability, and even sport "don't be evil" among their core values. I'm working in an industry that continuously takes hostile stances against the FOSS community, unofficial clients, and anything that doesn't fit neatly into the quarterly vision for profitability outlined in the PowerPoint deck of a sociopath product manager with no tech background, and who couldn't care less if they are selling IoT devices or bricks. And I have to dodge these attacks on a daily basis, one line of code at the time, for the hundreds of integrations available in the projects I maintain or contribute to, just to keep things working without losing features overnight.

    I wake up the morning thinking "how will tech companies decide to fuck me up today just to get one more byte about me to sell to data brokers, and which activities will I be forced to put aside in order to write some code that fixes the UX-breaking shitshow that one of their greedy managers has decided to put up today in an effort to beef up their quarterly bonus with a +1% uptick in revenue?"

    Congratulations, motherfuckers. Your broken business models have broken tech for everyone.

    rachelbythebay.com/w/2023/09/2

  46. #Platypush can now be installed via #RPM too!

    After setting up in the past few days my Drone CI automation to spit out a .deb package on every push and tag, I've decided to go the extra mile and also repeat the exercise for .rpm.

    I'm also satisfied with having both my APT and RPM repos now completely served from an S3-compatible bucket, with my Drone CI automation being completely in charge of updating the bucket on every push.

    Installing Platypush from RPM on Fedora is now as simple as:

    > wget -O /etc/yum.repos.d/platypush.repo 'rpm.platypush.tech/platypush.r'

    > yum install platypush

    (or platypush-git, if you want the bleeding edge version updated on every push).

    rpm.platypush.tech

  47. #Platypush now has an APT repo! apt.platypush.tech/

    `apt install platypush` is now finally a thing!

    This will make the installation process easier on the RPi and other Debian-based systems.

    My CI/CD pipelines so far were only spitting out an AUR build, but now we've also got some dear ol' .deb files automatically generated.

    It's also my first experience with serving a static website completely through an S3-compatible bucket. Big kudos to the folks at #Scaleway for providing an alternative to AWS that is much cheaper and also EU-based.

  48. ```
    $ uptime
    10:06:46 up 698 days, 7 min, 0 users, load average: 0.30, 0.24, 0.18
    ```

    Just realized that I've got #Platypush running my cameras on some Raspberry Pi Zeros in my house that haven't been rebooted in two years.

    The Platypush process itself has remained up and running in all this time, all while serving tens of camera feed requests per day.

    I guess I'll have to restart these machines some time soon, or get stuck with Python 3.7 indefinitely...

  49. #Platypush 0.50.1 is finally out! 🎉

    I started the entities framework refactor in April 2022, hoping that laying the foundations for the new API wouldn't have taken me more than a couple of weeks. It ended up taking more than a year, while rewriting half of the codebase in the process. And the codebase really gained a lot in stability and maturity during the process.

    There's still a lot more to come. Eventually all the integrations should communicate through the new entities API. Once everything is an entity that can be wrapped into a UI widget, the refactor of the dashboard engine will come next. And then there's this idea of automatically managing the configuration and the dependencies through the web panel itself, reducing most of the remaining entry barriers. And more integrations are on the backlog - among them, XMPP, torrent-csv and PirateWeather.

    I'm also looking for support for i18n and a11y. As it's growing into something bigger than a project that is mostly for myself and a few other enthusiastic geeks, it's time to bring languages other than English and also support those aria tags.

    Stay tuned for more news!

  50. A digital audio processing question for the #audio, #math and #physics geeks out there (and, of course, any intersection between the three). I thought that I understood audio synthesizing (and acoustics in general), but this problem is making me question all of my knowledge on the topic.

    Suppose that you have two sounds (say, for sake of simplicity, two MIDI notes, C4 and G4). They have their own associated fundamental frequencies f1 and f2.

    Suppose that you build two simple sine waves for each of them with numpy or whatever, and let's say that each has 1000 samples.

    The question is: how do I combine these two waves to give two different effects, at least to the human ear?

    - Effect 1: f1 and f2 are "perceived" as a one single sound, with harmonics ratio of 3/2 in the case above, and the frequency that is perceived as "dominant" is the one with the highest amplitude.

    - Effect 2: the sounds associated to f1 and f2 are "perceived" as distinct sounds that just happen to be played simultaneously - like in a chord.

    If I take the sum of two resulting sine waves (or, better, the two numpy samples of 1000 items each), normalize into an audio envelope, and send the resulting wave to the audio device, I get effect 1 - i.e. a fundamental frequency with some harmonics.

    In order to achieve effect 2, I have to open two distinct audio streams (read "clients"), and send wave 1 to stream 1 and wave 2 to stream 2.

    As I'm currently refactoring (and improving) the audio synthesizer extension of #Platypush, I find the latter solution quite inefficient - you may easily be on a system without Pulseaudio and/or with a limited amount of simultaneous sound outputs. Even in a Pulseaudio case with 32 channels, occupying each channel with a different note if I'm playing some polyphonic stuff is very inefficient.

    So I'd like to "stuff" even case 2 (i.e. distinct sine waves played simultaneously) into a case-1-like solution (i.e. massage the sine waves and end up with a sound wave with the combination of them - not one with a new sound with harmonics).

    And this made me wonder: from a mathematical and physical point of view, what makes the difference between the two cases? If I pluck two strings on my guitar at the exact same time, I perceive the resulting sound as a combination of two distinct waves each with its own fundamental frequency - not like a single sound with some upper harmonics given by the highest note.

    Intuitively, the two sounds combine and make the air molecules "ripple" with a wave that should be (again, intuitively) the sum of the two waves.

    So how come when I sum two waves on a computer I only get a single-note sound with harmonics? What makes the difference between the way our ears perceive those two cases? My educated guess says that it may have something to do with the phase, but my empirical results tell me that it can't be the difference in phase alone.

  51. I feel like I agree with all the points in this article.

    The points the author brought forward are the reasons why, unlike HomeAssistant, the code of #Platypush tends to avoid asyncio unless it's really required (e.g. the #Python websockets library and the SmartThings SDK both use the asyncio API).

    First, unlike JavaScript and Rust, using async/await in Python requires you to rewrite most of your code. You'll have to handle your own loop, its lifecycle, think of all the cases where a future may be invoked from another thread, make your own code thread-safe, etc.

    Second, asyncio+threads=guaranteed headache. asyncio loops are not thread safe. You'll also have to think of all the cases where an async function might be called from another thread, wrap it into a call_soon_thread_safe, and hope for the best. You'll have to be familiar with several implementation details - like that you can only run one loop per process, you can't access loops running on another process, and accessing loops running on another thread on the same process requires a different API. When you have to know so many implementation details to properly write your code, the programming language has failed its purpose of being usable and reliable in a production environment - that was the case with C++ during the period where it advised developers to be familiar with a dozen or so different types of pointers.

    And, if you're running a multithreaded application, you'll have to carefully think of how you initialize or use your event loop - I've eventually restored to a dirty workaround that tries to create a new loop, and tries to use the existing one if an exception is raised, because I got tired of thinking of all the cases where a loop may be needed in another thread. Having an async mechanism that is so broken when you put it to work together with threads means that the async mechanism is broken and poorly implemented, period. I've raised some of these points with some Python core developers, and they told me "just don't use asyncio with threads". Sorry, that's just not an option in a large modern application.

    Third, putting the burden of the loop lifecycle on the developer is a guarantee of bugs and flaky code. Should I start a new loop or try and check if one already exists? How to properly stop a loop? Do I have a thread-safe way of using an existing loop without the loop being pulled off my feet when I access it?

    Fourth, there's a big misunderstanding about asyncio and performance. Many say that asyncio can improve the performance of your application by increasing concurrency, and that's just plain false. asyncio facilitates concurrent access to I/O, but it does NOTHING to improve concurrency nor performance. Under the hood, asyncio is just syntactic sugar for a big select() on a pool of file descriptors. Given all the cognitive overhead that comes with managing that syntactic sugar, I've often found myself in the position where I just prefer to do a low-level select() on some sockets or file descriptors, and that takes me way less time than rewriting my whole application to be compatible with asyncio.

    If you want to scale/parallelize your application, use multiprocessing with a pool of workers. If those units need to share more code or data, use threads. But DON'T use asyncio. asyncio parallelizes access only when you call await on an I/O operation - that's when the interpreter knows that it can switch to another context. If there's no await on an I/O operation, then nothing will be parallelized. If you're running a CPU intensive operation with no I/O in an async function, then you're just adding overhead without actually gaining a single CPU cycle.

    charlesleifer.com/blog/asyncio

  52. It's good that other people are also bringing up the elephant in the room: why do you need to pay money for one more electronic gadget that listens to you 24/7, when voice assistants aren't supposed to be rocket science in 2023 anymore? news.ycombinator.com/item?id=3

    I wrote two articles on how to build custom #VoiceAssistants using just a Raspberry Pi and a microphone, one in 2019 blog.platypush.tech/article/Bu and one in 2020 blog.platypush.tech/article/Bu.
    It's definitely doable and I still have my own custom assistants in the house. However, I had to get around with a #Snowboy model for hotword detection (and Snowboy is now basically abandoned), Mozilla #DeepSpeech model for speech-to-text (and that's quite heavy), and #Mycroft's mimic3 text-to-speech model (and Mycroft is now basically bankrupt). Then writing the integration is relatively easy - I used #Platypush, but it can definitely be done with Home Assistant and OpenHAB too.

    Compared to 3-4 years ago, I think we're now in a state where the content is no longer the issue (just plug into a LLM, and all of your text requests will get an answer), nor integrations are a problem (just write a Platypush event hook on speech detected, and you can connect it to everything, no need for "Works with Google/Alexa" labels). Text-to-speech synthesis has also become cheap and ubiquitous.

    But the hotword detection and speech-to-text models are still IMHO the bottleneck. Hotword detection is a field where you need a very small and lightweight model that only detects a specific word or phrase in a very reliable way. Snowboy was an amazing FOSS project - which also came with this cool idea of "crowd-funded models", where in order to download a model for a certain hotword you were first supposed to provide three audio tracks where you say that word in order to improve the model. But it's now discontinued because it cost the volunteers too much to run the infra.

    And Mozilla DeepSpeech is a relatively good choice for general-purpose speech-to-text, but it's heavy (it takes 100% of the CPU when it runs on a Raspberry Pi) and it's mostly optimized for English - even support for other Western languages is patchy. OpenAI's recent Whisper model seems like a solid alternative, but it's also plagued by the 100% CPU issue - also, I no longer trust anything that comes from OpenAI, no matter how noble some of their efforts may look.

    If there are other open-source alternatives that solve these problems, I'd be very happy to learn about them. Once these blockers are removed, there should be really no reason for anyone to feed their audio streams to Google or Amazon.

    In the meantime, I'm planning to spend some time playing with some self-hosted LLM model to see if I can replace the Google Assistant library on the last Raspberry Pi that runs it in my home.

  53. The new #Platypush entities dashboard looks good. It took me months of work, but I'm finally getting to a point where everything can be shown in one place, and both the API and the style of all the entities is consistent. I now have a solid foundation to build features like groups, scenes, dashboards, entity widgets that can be rendered anywhere, and a UI to create automation routines (so even those who aren't proficient with Python or YAML can build cool things) that look and feel the same across all the integrations.

    All the new code is now on the main branch, but I don't feel confident to make a new release yet.

    My system has now ~1000 identified entities, and the UI starts to get way too slow with such numbers. I've been optimizing things for the past few days (like removing the loading animation for entities altogether so the browser doesn't have to render 1000 GIFs or CSS animations when the page loads), but things aren't as quick as I'd like yet. It still takes >1 minute for everything to load on my phone.

    I suspect that the next bottleneck to optimize is the websocket client - every entity update/refresh triggers a new event on the websocket, and the Vue app starts struggling keeping the data model up-to-date when it receives 1000 events within a couple of seconds. My browser is still there processing stuff long after my Raspberry Pis has pushed all the events on the websocket.

    I'm open to consider alternatives, but none of those that have come to my mind lately (server-side event throttling, bundling of multiple events in batches, lazy loading with all the entity groups initially collapsed until the user clicks on them) really satisfies me.

    Any web developers out there who have ideas?

  54. I have improved the loading performance of the new #Platypush entity dashboard by 200% with a simple fix.

    Using a font-awesome CSS class instead of an animated GIF for your loading spinner can make a huge difference, if that loading spinner is supposed to be used by 1000 components on a page.

    git.platypush.tech/platypush/p

  55. Almost 1000 changed files, 26300 additions, 10000 deletions, and more than a year later, the time has come to finally merge the largest PR I've ever worked on in my life.

    I started naming this PR the "Tool album PR": keep your work on hold for too long before releasing to the public, and the public will have increasingly high expectations of your work once you release it.

    Designing a framework in #Platypush that uses the same paradigms to model entities of any type (think of Bluetooth speakers, Zigbee lights, Z-Wave sensors, Smartthings/Hue integrations, CPU temperature sensors, smart TVs and buttons, Arduino/ESP machinery, media plugins, cloud instances etc. all sharing the same backend API and taxonomy, frontend building blocks and UI interface) has taken me through a long wild ride, and almost a total (and still in progress) rewrite of the platform.

    I'm quite satisfied of the results so far though. The new index page shows everything in one place, like a Google Home, Smartthings or Home Assistant dashboard, but I've added my twists to support the things I like - like smart dynamic grouping, filtering on-the-fly, and a strongly consistent way of naming things coming from different integrations. This will provide me a solid ground to implement entities as flexible widgets that can be imported anywhere. And it also makes it much easier to write reusable event hooks: you should ideally be able to subscribe to `EntityUpdateEvent` events that all look and feel the same regardless of the plugin and the entity type.

    There's a lot still on the plate, but instead of keeping this PR open for another year or so I'd rather merge now that things are reasonably stable, get feedback, and build more incrementally from now on.

    A lot of wiki documentation (and instructions in blog articles) needs to be updated, but the latest docs at docs.platypush.tech already references the new interface. I probably need to put together a big CHANGELOG entry to document all the breaking changes (although I've tried to keep them to a minimum). There's also the migration to SQLAlchemy 2.0 ticket looming on the horizon.

    And then more integrations that need to be migrated to the new framework. Media entities (music and video players, cameras, Chromecasts, multi-room audio plugins etc.) are next on the roadmap, followed by voice assistants and messaging services.

    Then there's the support for groups and scenes, the integration with existing groups and scenes (e.g. on the Hue, Smartthings, Zigbee or Z-Wave integration), as well as the creation of smart dashboards and views with custom groupings of entities - ideally I'd like to make dashboards easily configurable with custom entities through the UI itself, while the current process still requires getting the hands dirty with some XML templating.

    The PR is now fully merged into the main trunk, but I'm happy if someone could test it out before I package a new stable release.

    git.platypush.tech/platypush/p

  56. A teaser of the new #Platypush entities UI.

    Because one panel per integration is nice, but having all the integrations in one place, with all of their entities speaking the same language, is even nicer.

    For the impatient, you can try the revamped version of the app using this branch: git.platypush.tech/platypush/p. I've already been running it for a couple of days on most of my devices and nothing major seems to be broken.

    Cons: if you have many integrations with many very "active" entities the performance may be a bit slow, since the UI may have to load hundreds of entities while processing several messages per second. But this will probably become less relevant once I add the support for adding entities to custom groups, scenes or dashboards.

    To the UI/UX folks out there: I like how the interface looks, but I feel like I'm missing something to make it more "fluid". The list renders differently on different devices and all, but I still have that "boring static lists all the way down" feeling shouting from everywhere. On the other end, I'm not a big fan of the "big buttons on a two column layout" adopted by both SmartThings and Google Home either - it can work if you have a dozen of devices or so, but in Platypush's case it may end up rendering ~100/200 entities, and the user may end up stuck in a doomscroll unless they use the search bar.

  57. I started this PR almost a year ago git.platypush.tech/platypush/p

    One year and almost 28k LoC changed later, I feel like it's time to wrap things up, spin off the remaining tasks as separate tickets, and prepare a new big release of #Platypush.

    The new big release may come with some breaking changes, even though I tried to maximize back-compatibility, and some more major changes may come in the upcoming months. But I like overall how this project is growing.

    The new PR brings support for general-purpose entities - you can think of Bluetooth devices, Linode/AWS instances, Zigbee lights, Z-Wave sensors, Wi-Fi switches, media players etc. all being backed by the same consistent and documented relational schema and type-based taxonomy (e.g. Entity -> Device -> Sensor -> NumericSensor -> TemperatureSensor), and each entity supporting a whole hierarchy of entities underneath (like a Z-Wave multi-sensor device recursively bundling all of its values as separate entities under the same entity). All available in the same UI and exposing the same API. In the future you can create hooks on `EntityUpdateEvent` on top of the existing per-integration custom events, and all the payloads will have the same base format.

    A lot more is on the roadmap - proper support for inter-plugin groups and scenes of entities, possibility to configure plugins, hooks and procedures directly from the UI through something Node-Red inspired, and more integrations will adopt to the new specifications (music and media players, Chromecast, smart TVs, cameras and the long tail of custom sensor plugins are next on the list). Oh, and also an official Docker image and configuration/db backup and sync, so the initial learning curve can be much smoother.

    I started this project years ago as an effort to put together all of my hacky #Python scripts for #automation under the same roof. I've had plenty of hesitations along the road - mainly when #HomeAssistant became the de-facto FOSS standard for automation. And while it's hard to compete on my own with all the efforts that go into HASS, I feel like Platypush is getting more and more its own purpose.

    While HASS is increasingly becoming focused on being a hub that bring together as many proprietary integrations as possible, Platypush still has a strong culture of supporting self-hosted and DIY solutions as first-class citizens - even though it also supports several major proprietary services like e.g. some of Google's cloud services, Alexa or Philips Hue.

    And while HASS is increasingly focusing on shipping its own environment on its own devices, I feel like I've succeeded so far in keeping Platypush platform-agnostic and lightweight - it can still run with almost no overhead on a RPi0, you can even run it on Windows or MacOS if you want, and on anything that comes with a decent Python interpreter.

    Keeping this project as general-purpose, platform-agnostic and lightweight as possible has definitely come with its challenges (and a lot of lessons learned along the way), but I feel like the results are slowly starting to pay off.

  58. I have been diving deep into the world of #Bluetooth lately while refactoring #Platypush - I basically want it to be able to detect and communicate with as many devices as possible out of the box, including all BT stuff.

    And I've been really puzzled by the (often forgotten) world of BLE beacons.

    While building the new UI to show the scanned devices, I have noticed TONS of beacons from devices with random MAC addresses, no name, and no known services besides exposure notification and proximity identifier.

    Most of them report Apple or Google as manufacturer IDs, but there are many with no reported manufacturer at all - but they still report service UUIDs like 0xfe9f or 0xfef3, which are registered by Google. Interesting findings:

    - There's only one Apple device in my house (my wife's work MacBook), no AirTags and all, but there are about 20-25 Apple Bluetooth devices scanned in a single hour.

    - The more I leave my app on, the more new devices it detects. Even assuming that there are other devices in my neighbors' apartments, Bluetooth usually doesn't cover distances >10m. So I'd expect to see max ~10-20 devices at some point, taking all the smartphones, laptops, Chromecasts, smart TVs etc. into account, and the number should become stable at some point. Instead, we're talking about ~50-100 devices scanned within 2 hours, and the numbers keep going up the more I leave the process active. So it seems that some devices keeps generating new MAC addresses.

    I couldn't find much online when searching for some of those service UUIDs, except that they are used by Apple's and Google's BLE beacon protocols. Of course I have a vague idea of how Google and Apple may be using this technology, but are there more insights on the protocols and what's been exchanged?

    And, most importantly, is there a documented way of excluding this beacon spam from my scanner? Filtering on manufacturer doesn't suffice, since many of these devices have no registered manufacturers, and I'd need to have an always up-to-date list of whatever GATT UUIDs they register to be able to reliably exclude them on a service basis.

    Platypush's UI has been designed to easily handle ~100 smart devices in a single view, but if Google and Apple flood me with hundreds of spam devices a day, each of them pushing several messages per second, then the performance of the app is badly impacted...

  59. As part of my refactor of #Platypush, I've been planning to publish official #Docker images to the Hub. It sounds like it's a good chance to start taking a look at the #Gitea container registry - and hope that the bandwidth requirements won't kill my server blog.alexellis.io/docker-is-de