Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Add timelapse to Media and show rendering videos #477

Open
leinich opened this issue Feb 27, 2024 · 10 comments
Open

[Feature] Add timelapse to Media and show rendering videos #477

leinich opened this issue Feb 27, 2024 · 10 comments
Labels
feature request New feature or request

Comments

@leinich
Copy link

leinich commented Feb 27, 2024

Describe the feature

It would be great to show the current rendered 3D print on homeassistant dashboard or to be able to view the timelapse (or even send it via push notification)

Currently it is possible to access timelapse and rendering previews of the prints via SFTP.
The data can be accessed following:
Protocol: sftp (Require implicit FTP over TLS )
User: bblp
Password: AccessToken as shown on the screen

Unfortunatelly there is no easy way to currently use SFTP with homeassistant

What device is this for?

P1S

Other Information

No response

@leinich leinich added the feature request New feature or request label Feb 27, 2024
@AdrianGarside
Copy link
Collaborator

Which is the rendered 3d print file? Is it different to the cover image that home assistant already makes available when the printer is not in lan mode?

@leinich
Copy link
Author

leinich commented Feb 28, 2024

@AdrianGarside
the rendered printjob is in the folder /image and seems to be the same as the image bambus studio shows in the last printing dialog.
and looks like following: /image/34113264191.png
File sizes are around 1 to 7KB

As I am only using the printer in LAN mode, i have never seen any cover image files in Home Assistant exposed.

The timelapse files are stored in the folder /timelapse and sorted the a timestamp
/timelapse/timelapse_2024-02-25_05-51-06.avi

@WolfwithSword
Copy link
Contributor

WolfwithSword commented Mar 1, 2024

Since there isn't a way to do FTPS (or sftp, which is a different protocol than the printers use) easily within HA, perhaps it might be worth looking into some way to use another hacs integration, addon or script to perform the FTPS fetch based on an automation from ha-bambulab?

For example, you could run a python-script using the Python Scripts integration.

Note, I have not tested anything using the python-script integration, this is just giving an idea for functionality

Here's a "working" python script for creating the FTPS connection.

import ftplib
import ssl
import platform

ftplib.ssl_version = ssl.PROTOCOL_TLSv1_2

USER = "bblp"
ACCESS_CODE = "<access_code>"
PRINTER_IP = "<printer_ip>"

class ImplicitFTP_TLS(ftplib.FTP_TLS):
    """FTP_TLS subclass that automatically wraps sockets in SSL to support implicit FTPS."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._sock = None

    @property
    def sock(self):
        """Return the socket."""
        return self._sock

    @sock.setter
    def sock(self, value):
        """When modifying the socket, ensure that it is ssl wrapped."""
        if value is not None and not isinstance(value, ssl.SSLSocket):
            value = self.context.wrap_socket(value)
        self._sock = value

    def ntransfercmd(self, cmd, rest=None):
        conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest)
        if self._prot_p:
            session = self.sock.session
            if isinstance(self.sock, ssl.SSLSocket):
                    session = self.sock.session
            conn = self.context.wrap_socket(conn,
                                            server_hostname=self.host,
                                            session=session)  # this is the fix
        return conn, size
        

ftps = ImplicitFTP_TLS()

ftps.connect(host=PRINTER_IP, port=990)

ftps.login(user=USER, passwd=ACCESS_CODE)
ftps.prot_p()

### Connection setup above, always required
### Logic below

## After logic
ftps.close()

You can use ftps.nlst() to list files. You can specify a directory in the nlst command, but note that results on P1 and A1 series at least (and I think X1 series, but I've long since fixed this to remember) do not prefix the provided path to the results.

You do have to use nlst though, as the other list command does not work on A1/P1. Also, wildcard searches or extension type searches do not work for A1/P1 series, so filtering must be done after getting results.

To download the file, you can do the following:

remote_file_with_path = "<from filtered results of nlst earlier>"
local_file_with_path = "<somewhere_in_HA_media?>"
with open(local_file_with_path , 'wb') as f:
    ftps.retrbinary('RETR ' + remote_file_with_path, f.write)

I can verify that listing and downloading from the printer via python ftps like this (standalone python program, not in HA) works for all series of bambu printers as of writing.

So theoretically, using python-scripts integration someone could piece together an automation blueprint that on print finish/failed, wait a few seconds, fetch a list of all timelapses, sort by name (timestamp format will sort as it's YYYY-MM-DD) and get newest that way. Download to HA's media folder and do something else with it?

Do note that the printer does not contain the generated model images like the "cover image" for X1C. I expect it is only on the P1 and A1 series due to those printers unpacking the 3mf on card or in cloud and downloading the results due to performance reasons. So for reliability reasons, if someone makes this an external automation for the "cover image rendering", it won't work for X1 printers without downloading and extracting the 3mf. There is no /image directory for X1 printers.

Additionally, timelapses are formatted differently on X1 series. It is in folder /timelapse with filename pattern and filename type "video_YYYY-mm-dd_HH-MM-SS.mp4". For example, /timelapse/video_2023-08-21_09-24-47.mp4. There also exists a /timelapse/thumbnail directory, which contains a thumbnail for each timelapse photo of same name but .jpg extension.

Full-recordings (not timelapse, realtime) are in /ipcam folder, and similar format regarding timelapse and thumbnail file location/naming, except instead of "video" it's "ipcam-record." with the dot instead of an underscore upfront.

And this likely cannot be used easily to download the whole 3mf file as there is the added step of needing to unzip it (likely into memory due to HA limitations) and then parse its internal data. But if it does work, opens up a lot of options.

Though, if using the python-scripts integration and a modified script like this works, it could open up an "advanced" usage for ha-bambulab maybe @AdrianGarside @greghesp thoughts? Might be worth looking into to create a blueprint for, though it likely won't add any data to the integration devices, but only provide media-files in HA, and that's if the python-script integration even allows access to the directory, which again I am not certain of.

@AdrianGarside
Copy link
Collaborator

I wrote a short python script way back that could download files via ftps. At the time they’d only just enabled it on the P1P and it was quite easy to hang the printer but hopefully that’s been fixed by now since they’ve finally added the Timelapse viewing in the slicer.

Copy link

github-actions bot commented May 1, 2024

Stale issue message

@MrSco
Copy link

MrSco commented Jan 12, 2025

has anyone gotten this to work on an X1? would be very nice to not have to take out the sd card to get timelapses when using LAN only mode....

Since there isn't a way to do FTPS (or sftp, which is a different protocol than the printers use) easily within HA, perhaps it might be worth looking into some way to use another hacs integration, addon or script to perform the FTPS fetch based on an automation from ha-bambulab?

For example, you could run a python-script using the Python Scripts integration.

Note, I have not tested anything using the python-script integration, this is just giving an idea for functionality

Here's a "working" python script for creating the FTPS connection.

import ftplib
import ssl
import platform

ftplib.ssl_version = ssl.PROTOCOL_TLSv1_2

USER = "bblp"
ACCESS_CODE = "<access_code>"
PRINTER_IP = "<printer_ip>"

class ImplicitFTP_TLS(ftplib.FTP_TLS):
    """FTP_TLS subclass that automatically wraps sockets in SSL to support implicit FTPS."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._sock = None

    @property
    def sock(self):
        """Return the socket."""
        return self._sock

    @sock.setter
    def sock(self, value):
        """When modifying the socket, ensure that it is ssl wrapped."""
        if value is not None and not isinstance(value, ssl.SSLSocket):
            value = self.context.wrap_socket(value)
        self._sock = value

    def ntransfercmd(self, cmd, rest=None):
        conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest)
        if self._prot_p:
            session = self.sock.session
            if isinstance(self.sock, ssl.SSLSocket):
                    session = self.sock.session
            conn = self.context.wrap_socket(conn,
                                            server_hostname=self.host,
                                            session=session)  # this is the fix
        return conn, size
        

ftps = ImplicitFTP_TLS()

ftps.connect(host=PRINTER_IP, port=990)

ftps.login(user=USER, passwd=ACCESS_CODE)
ftps.prot_p()

### Connection setup above, always required
### Logic below

## After logic
ftps.close()

You can use ftps.nlst() to list files. You can specify a directory in the nlst command, but note that results on P1 and A1 series at least (and I think X1 series, but I've long since fixed this to remember) do not prefix the provided path to the results.

You do have to use nlst though, as the other list command does not work on A1/P1. Also, wildcard searches or extension type searches do not work for A1/P1 series, so filtering must be done after getting results.

To download the file, you can do the following:

remote_file_with_path = "<from filtered results of nlst earlier>"
local_file_with_path = "<somewhere_in_HA_media?>"
with open(local_file_with_path , 'wb') as f:
    ftps.retrbinary('RETR ' + remote_file_with_path, f.write)

I can verify that listing and downloading from the printer via python ftps like this (standalone python program, not in HA) works for all series of bambu printers as of writing.

So theoretically, using python-scripts integration someone could piece together an automation blueprint that on print finish/failed, wait a few seconds, fetch a list of all timelapses, sort by name (timestamp format will sort as it's YYYY-MM-DD) and get newest that way. Download to HA's media folder and do something else with it?

Do note that the printer does not contain the generated model images like the "cover image" for X1C. I expect it is only on the P1 and A1 series due to those printers unpacking the 3mf on card or in cloud and downloading the results due to performance reasons. So for reliability reasons, if someone makes this an external automation for the "cover image rendering", it won't work for X1 printers without downloading and extracting the 3mf. There is no /image directory for X1 printers.

Additionally, timelapses are formatted differently on X1 series. It is in folder /timelapse with filename pattern and filename type "video_YYYY-mm-dd_HH-MM-SS.mp4". For example, /timelapse/video_2023-08-21_09-24-47.mp4. There also exists a /timelapse/thumbnail directory, which contains a thumbnail for each timelapse photo of same name but .jpg extension.

Full-recordings (not timelapse, realtime) are in /ipcam folder, and similar format regarding timelapse and thumbnail file location/naming, except instead of "video" it's "ipcam-record." with the dot instead of an underscore upfront.

And this likely cannot be used easily to download the whole 3mf file as there is the added step of needing to unzip it (likely into memory due to HA limitations) and then parse its internal data. But if it does work, opens up a lot of options.

Though, if using the python-scripts integration and a modified script like this works, it could open up an "advanced" usage for ha-bambulab maybe @AdrianGarside @greghesp thoughts? Might be worth looking into to create a blueprint for, though it likely won't add any data to the integration devices, but only provide media-files in HA, and that's if the python-script integration even allows access to the directory, which again I am not certain of.

@AdrianGarside
Copy link
Collaborator

The download via ftps isn't the problem although I'm not sure if there are limitations on where an integration can store the downloaded files. What I don't know how to do is then expose that to the end user in home assistant.

@jneilliii
Copy link

Yeah, you have to middle man the list and downloading via ftps. I've done this on the OctoPrint plugin to make the local timelapse list available in the front end and then on download kind of proxy the request through to download.

@MrSco
Copy link

MrSco commented Jan 13, 2025

I was able to cobble together a couple python shell_command's using @WolfwithSword 's script as a starting point that I can run in automations...

when prints start, grab the current task filename 3mf or newest .3mf file's image to show as cover_image (since this is broken in LAN mode)

when prints finish, grab the latest timelapse video from my X1C

import ftplib
import os
import ssl
import zipfile
from datetime import datetime
import tempfile
import argparse
import logging  # Add logging import
from ftplib import error_perm, error_temp, error_reply, all_errors
import sys

# Set up logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)

ftplib.ssl_version = ssl.PROTOCOL_TLSv1_2

USER = "bblp"
ACCESS_CODE = "<access_code>"
PRINTER_IP = "x.x.x.x"

# set flag to check if we are on home assistant
HA_MODE = os.getenv('SUPERVISOR_TOKEN') is not None
TIMELAPSE_DIR = "/timelapse"
LOCAL_TIMELAPSE_PATH = "/config/www/media/ha-bambulab-timelapse-downloader/timelapses/"
if not HA_MODE:
    LOCAL_TIMELAPSE_PATH = os.path.dirname(os.path.abspath(__file__)) + "/timelapses/"
LOCAL_DOWNLOAD_PATH = "/config/www/media/ha-bambulab-timelapse-downloader/thumbnails/"
if not HA_MODE:
    LOCAL_DOWNLOAD_PATH = os.path.dirname(os.path.abspath(__file__)) + "/thumbnails/"
PRINT_FILE_DIR = "/"

class ImplicitFTP_TLS(ftplib.FTP_TLS):
    """FTP_TLS subclass that automatically wraps sockets in SSL to support implicit FTPS."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._sock = None

    @property
    def sock(self):
        """Return the socket."""
        return self._sock

    @sock.setter
    def sock(self, value):
        """When modifying the socket, ensure that it is ssl wrapped."""
        if value is not None and not isinstance(value, ssl.SSLSocket):
            value = self.context.wrap_socket(value)
        self._sock = value

    def ntransfercmd(self, cmd, rest=None):
        conn, size = ftplib.FTP.ntransfercmd(self, cmd, rest)
        if self._prot_p:
            session = self.sock.session
            if isinstance(self.sock, ssl.SSLSocket):
                    session = self.sock.session
            conn = self.context.wrap_socket(conn,
                                            server_hostname=self.host,
                                            session=session)  # this is the fix
        return conn, size
        

try:
    ftps = ImplicitFTP_TLS()
    ftps.connect(host=PRINTER_IP, port=990)
    ftps.login(user=USER, passwd=ACCESS_CODE)
    ftps.prot_p()
except error_perm as e:
    logging.error(f"FTP Authentication error: {e}")
    sys.exit(1)
except error_temp as e:
    logging.error(f"FTP Temporary error (server may be down): {e}")
    sys.exit(1)
except all_errors as e:
    logging.error(f"FTP Connection error: {e}")
    sys.exit(1)
except Exception as e:
    logging.error(f"Unexpected error during FTP connection: {e}")
    sys.exit(1)


# Create directories if they don't exist
os.makedirs(LOCAL_TIMELAPSE_PATH, exist_ok=True)
os.makedirs(LOCAL_DOWNLOAD_PATH, exist_ok=True)

def parse_arguments():
    parser = argparse.ArgumentParser(description='Download files from Bambu Lab printer')
    parser.add_argument('--timelapse', action='store_true', 
                        help='Download the latest timelapse video')
    parser.add_argument('--thumbnail', action='store_true',
                        help='Extract thumbnail from latest 3MF file')
    parser.add_argument('--filename', nargs='?', const='',
                        help='Specific 3MF filename to extract thumbnail from (defaults to latest if file not found)')
    return parser.parse_args()

def main():
    args = parse_arguments()
    
    if not args.timelapse and not args.thumbnail:
        logging.error("Please specify either --timelapse or --thumbnail")
        return

    if args.timelapse:
        try:
            # Get list of timelapses
            files = ftps.nlst(TIMELAPSE_DIR)
            # Filter for MP4 files and ensure they're from timelapse directory
            timelapse_files = [f for f in files if f.endswith('.mp4') and 'video_' in f]
            # Sort by name (which contains timestamp) to get newest first
            timelapse_files.sort(reverse=True)

            if timelapse_files:
                newest_timelapse = timelapse_files[0]
                local_file_path = LOCAL_TIMELAPSE_PATH + newest_timelapse.split('/')[-1]
                
                # Get remote file size
                ftps.voidcmd('TYPE I')  # Switch to binary mode
                remote_size = ftps.size(newest_timelapse)
                
                # Check if file exists locally and sizes match
                if os.path.exists(local_file_path):
                    local_size = os.path.getsize(local_file_path)
                    if local_size == remote_size:
                        logging.info(f"Timelapse {newest_timelapse.split('/')[-1]} already exists locally with matching size, skipping download")
                    else:
                        logging.info(f"Timelapse exists but size differs (local: {local_size}, remote: {remote_size}), downloading newer version")
                        with open(local_file_path, 'wb') as f:
                            ftps.retrbinary('RETR ' + newest_timelapse, f.write)
                        logging.info(f"Downloaded timelapse to: {local_file_path}")
                else:
                    with open(local_file_path, 'wb') as f:
                        ftps.retrbinary('RETR ' + newest_timelapse, f.write)
                    logging.info(f"Downloaded timelapse to: {local_file_path}")
        except error_temp as e:
            logging.error(f"Temporary FTP error during timelapse download: {e}")
            return
        except all_errors as e:
            logging.error(f"FTP error during timelapse download: {e}")
            return

    if args.thumbnail:
        try:
            logging.info(f"Scanning directory {PRINT_FILE_DIR} for 3MF files...")
            files = ftps.nlst(PRINT_FILE_DIR)
            logging.info(f"Found {len(files)} total files")
            
            # Get list of 3MF files with their timestamps
            mf_files = []
            for f in files:
                if f.endswith('.3mf'):
                    try:
                        # Get file modification time
                        ftps.voidcmd('TYPE I')
                        timestamp = ftps.sendcmd('MDTM ' + f)
                        if timestamp.startswith('213'):  # Success code
                            # Parse timestamp (format: YYYYMMDDHHMMSS)
                            timestamp = timestamp[4:]  # Remove '213 ' prefix
                            mf_files.append((f, timestamp))
                    except ftplib.error_perm:
                        # If MDTM fails, still include the file but with old timestamp
                        mf_files.append((f, '00000000000000'))
            
            logging.info(f"Found {len(mf_files)} 3MF files")
            
            if not mf_files:
                logging.error("No 3MF files found in directory")
                return

            # Sort by timestamp to get newest first
            mf_files.sort(key=lambda x: x[1], reverse=True)
            
            # Select the 3MF file based on filename argument or default to newest
            target_3mf = None
            if args.filename and args.filename.strip():  # Check if filename is provided and not just whitespace
                # Look for the specified file
                matching_files = [f for f, _ in mf_files if f.endswith(args.filename)]
                if matching_files:
                    target_3mf = matching_files[0]
                    logging.info(f"Found specified file: {target_3mf}")
                else:
                    logging.warning(f"Specified file '{args.filename}' not found, defaulting to latest 3MF")
                    target_3mf = mf_files[0][0]
            else:
                target_3mf = mf_files[0][0]  # Get just the filename
                logging.info("No filename specified or blank filename provided, using latest 3MF")
                
            logging.info(f"Processing 3MF file: {target_3mf}")
            
            # Create temp directory for extraction
            with tempfile.TemporaryDirectory() as temp_dir:
                # Download the 3MF file
                temp_3mf_path = os.path.join(temp_dir, "temp.3mf")
                logging.info(f"Downloading 3MF file to {temp_3mf_path}")
                
                with open(temp_3mf_path, 'wb') as f:
                    ftps.retrbinary('RETR ' + target_3mf, f.write)
                
                if not os.path.exists(temp_3mf_path):
                    logging.error("Failed to download 3MF file")
                    return
                    
                logging.info("Extracting 3MF file...")
                # Extract the 3MF file (it's just a ZIP)
                with zipfile.ZipFile(temp_3mf_path, 'r') as zip_ref:
                    # List all files in the zip for debugging
                    file_list = zip_ref.namelist()
                    logging.info(f"Files in 3MF archive: {file_list}")
                    
                    # Extract Thumbnail image
                    thumbnail_found = False
                    # First try to get any main plate preview
                    target_files = [f for f in file_list if f.startswith('Metadata/plate_') and f.endswith('.png') 
                                  and not f.endswith('_small.png') and not f.endswith('_no_light.png')]
                    if not target_files:
                        # Fallback to small preview if main not found
                        target_files = [f for f in file_list if f.startswith('Metadata/plate_') and f.endswith('_small.png')]
                    
                    if target_files:
                        # Sort to get the lowest plate number (usually the main one)
                        target_files.sort()
                        file = target_files[0]
                        thumbnail_found = True
                        logging.info(f"Found thumbnail: {file}")
                        thumbnail_path = os.path.join(LOCAL_DOWNLOAD_PATH, f"latest_model_thumbnail.png")
                        
                        with zip_ref.open(file) as source, open(thumbnail_path, 'wb') as target:
                            target.write(source.read())
                        logging.info(f"Successfully extracted thumbnail to: {thumbnail_path}")
                    
                    if not thumbnail_found:
                        logging.error("No thumbnail found in 3MF file")
                        
        except ftplib.error_perm as e:
            logging.error(f"FTP Permission error: {e}")
        except ftplib.error_temp as e:
            logging.error(f"FTP Temporary error: {e}")
        except zipfile.BadZipFile:
            logging.error("Invalid or corrupted 3MF file")
        except Exception as e:
            logging.error(f"Unexpected error: {e}")

if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        logging.error(f"Fatal error: {e}")
    finally:
        logging.info("Closing FTP connection")
        ftps.close()

in my command_line.yaml ...

download_bambu_timelapse: "python /config/python_scripts/ha-bambulab-timelapse-downloader.py --timelapse"
download_bambu_thumbnail: "python /config/python_scripts/ha-bambulab-timelapse-downloader.py --thumbnail  --filename {{ filename }}"

in my print started automation actions...

action: shell_command.download_bambu_thumbnail
metadata: {}
data:
  filename: "{{ states('sensor.printy_mccraftface_task_name') }}"

and on my bambu dashboard page I made some adjustments to point to the downloaded thumbnail for the cover image...

...
elements:
          - type: conditional
            conditions:
              - entity: sensor.printy_mccraftface_print_status
                state_not: offline
              - entity: sensor.printy_mccraftface_print_status
                state_not: unknown
            elements:
              - type: custom:hui-element
                card_type: picture
                show_name: false
                show_state: false
                image: >-
                  /local/media/ha-bambulab-timelapse-downloader/thumbnails/latest_model_thumbnail.png
...                  

and added a gallery-card using files component to view the timelapse folder...

type: custom:gallery-card
entities:
  - sensor.bambulab_timelapses
menu_alignment: bottom
maximum_files: 10
file_name_format: video_YYYY-MM-DD_HH-mm-ss
caption_format: M/D h:mm A
reverse_sort: true
show_reload: true

chrome_fgbCN8gmBt

I hope this helps someone!

@AdrianGarside
Copy link
Collaborator

I have it downloading the latest video and thumbnail to a path under www\media on print end. I haven't yet been able to successfuly show that in custom:gallery-card but it seems that integration is end-of-life anyway. Plus I don't really want to have to rely on other users installing other integrations to make this integration fully functional.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature request New feature or request
Projects
None yet
Development

No branches or pull requests

5 participants