├── .gitignore ├── requirements-to-freeze.txt ├── ezshare_resmed_default.ini ├── requirements.txt ├── uninstall_ezshare.sh ├── uninstall_ezshare.bat ├── local_bin_path.ps1 ├── ezshare_resmed_example_config.ini ├── ezshare_resmed ├── ezshare_resmed.cmd ├── .vscode └── launch.json ├── LICENSE ├── install_ezshare.bat ├── background.txt ├── install_ezshare.sh ├── ezshare_generic.py ├── ReadMe-Generic.txt ├── ReadMe.txt ├── README.md └── ezshare_resmed.py /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | -------------------------------------------------------------------------------- /requirements-to-freeze.txt: -------------------------------------------------------------------------------- 1 | requests 2 | beautifulsoup4 3 | tqdm -------------------------------------------------------------------------------- /ezshare_resmed_default.ini: -------------------------------------------------------------------------------- 1 | [ezshare_resmed] 2 | show_progress = True 3 | ssid = ez Share 4 | psk = 88888888 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.12.3 2 | certifi==2024.6.2 3 | charset-normalizer==3.3.2 4 | idna==3.7 5 | requests==2.32.3 6 | soupsieve==2.5 7 | tqdm==4.66.4 8 | urllib3==2.2.1 9 | -------------------------------------------------------------------------------- /uninstall_ezshare.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | rm -rf $HOME/.venv/ezshare_resmed 4 | rm $HOME/.local/bin/ezshare_resmed 5 | rm $HOME/.local/bin/ezshare_resmed.py 6 | echo "ezshare_resmed uninstalled" 7 | -------------------------------------------------------------------------------- /uninstall_ezshare.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | rmdir /s /q %USERPROFILE%\.venv\ezshare_resmed 4 | del %USERPROFILE%\.local\bin\ezshare_resmed.cmd 5 | del %USERPROFILE%\.local\bin\ezshare_resmed.py 6 | echo ezshare_resmed uninstalled 7 | -------------------------------------------------------------------------------- /local_bin_path.ps1: -------------------------------------------------------------------------------- 1 | $value = Get-ItemProperty -Path HKCU:\Environment -Name Path 2 | if (! ($value.Path.contains( $env:USERPROFILE + "\.local\bin"))) { 3 | $newpath = $value.Path += ";%USERPROFILE%\.local\bin" 4 | Set-ItemProperty -Path HKCU:\Environment -Name Path -Value $newpath 5 | } 6 | -------------------------------------------------------------------------------- /ezshare_resmed_example_config.ini: -------------------------------------------------------------------------------- 1 | [ezshare_resmed] 2 | path = ~/Documents/CPAP_Data/SD_card 3 | url = http://192.168.4.1/dir?dir=A: 4 | start_from = 20230924 5 | day_count = 5 6 | show_progress = True 7 | verbose = False 8 | overwrite = False 9 | keep_old = False 10 | ignore = JOURNAL.JNL,ezshare.cfg,System Volume Information 11 | ssid = ez Share 12 | psk = 88888888 13 | retries = 5 14 | -------------------------------------------------------------------------------- /ezshare_resmed: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | check_venv() { 4 | local env_name=${1:-".venv"} 5 | 6 | if [ -d "$env_name" ]; then 7 | true 8 | else 9 | false 10 | fi 11 | } 12 | 13 | venv_name=~/.venv/ezshare_resmed 14 | if ! check_venv $venv_name ; then 15 | echo "$venv_name is not present. Run ./install_ezshare.sh to setup environment" 16 | exit 1 17 | fi 18 | source "$venv_name/bin/activate" 19 | $(dirname "$0")/ezshare_resmed.py $@ 20 | -------------------------------------------------------------------------------- /ezshare_resmed.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | SETLOCAL 3 | 4 | SET venv_name=%USERPROFILE%\.venv\ezshare_resmed 5 | SET script_dir=%~dp0 6 | 7 | CALL :check_venv %venv_name%,venv_exists 8 | 9 | IF %venv_exists%==0 ( 10 | echo "%venv_name% is not present. Run install_ezshare.bat to setup environment" 11 | ) 12 | "%venv_name%\Scripts\python" %script_dir%ezshare_resmed.py %* 13 | 14 | EXIT /B %ERRORLEVEL% 15 | 16 | :check_venv 17 | IF EXIST "%~1\" ( 18 | SET /A %~2=1 19 | ) ELSE ( 20 | SET /A %~2=0 21 | ) 22 | EXIT /B 0 23 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python Debugger: Current File with Arguments", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal", 13 | "args": [] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 JCOvergaar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /install_ezshare.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | SETLOCAL 3 | 4 | SET venv_name=%USERPROFILE%\.venv\ezshare_resmed 5 | CALL :check_python python_exists 6 | 7 | IF %python_exists%==0 ( 8 | ECHO Python not installed, please install 9 | EXIT /B 1 10 | ) 11 | 12 | CALL :check_venv %venv_name%,venv_exists 13 | 14 | IF %venv_exists%==0 ( 15 | CALL :create_venv %venv_name% 16 | ) 17 | 18 | CALL :install_deps %venv_name% 19 | mkdir %USERPROFILE%\.local\bin 20 | mkdir %USERPROFILE%\.config\ezshare_resmed 21 | copy ezshare_resmed.cmd %USERPROFILE%\.local\bin 22 | copy ezshare_resmed.py %USERPROFILE%\.local\bin 23 | IF NOT EXIST "%USERPROFILE%\.config\ezshare_resmed\config.ini" ( 24 | copy ezshare_resmed_default.ini %USERPROFILE%\.config\ezshare_resmed\config.ini 25 | ) 26 | 27 | SET script_dir=%~dp0 28 | SET ps_script_path=%script_dir%local_bin_path.ps1 29 | PowerShell -NoProfile -ExecutionPolicy Bypass -Command "& '%ps_script_path%'"; 30 | 31 | echo. 32 | echo Installation complete 33 | echo Default configuration file is %USERPROFILE%\.config\ezshare_resmed\config.ini 34 | echo Run with: 35 | echo. 36 | echo ezshare_resmed 37 | 38 | EXIT /B %ERRORLEVEL% 39 | 40 | :create_venv 41 | python -m venv %~1 42 | EXIT /B 0 43 | 44 | :install_deps 45 | CALL %~1\Scripts\activate.bat 46 | 47 | IF EXIST ".\requirements.txt" ( 48 | pip install -r ".\requirements.txt" 49 | ) 50 | 51 | IF EXIST ".\setup.py" ( 52 | pip install -e . 53 | ) 54 | EXIT /B 0 55 | 56 | :check_venv 57 | IF EXIST "%~1\" ( 58 | SET /A %~2=1 59 | ) ELSE ( 60 | SET /A %~2=0 61 | ) 62 | EXIT /B 0 63 | 64 | :check_python 65 | WHERE python >nul 2>nul 66 | IF %ERRORLEVEL% EQU 0 ( 67 | SET /A %~1=1 68 | ) ELSE ( 69 | SET /A %~1=0 70 | ) 71 | EXIT /B 0 72 | -------------------------------------------------------------------------------- /background.txt: -------------------------------------------------------------------------------- 1 | Why did I do this? 2 | I got tired of taking my SD card out of my Airsense 11 every few days to import data into OSCAR, so I looked for an alternate option. 3 | 4 | The mainstream companies that created wifi SD cards discontinued them years ago and they are very expensive on the resale market. 5 | It looks like the only option still being made is the EzShare -- an inexpensive card/adapter intended for cameras, a knockoff of the EyeFi. 6 | 7 | Last year, I ran across a post from someone who had to use an EzShare SD card a few years ago. 8 | They weren't successful with it, but they made enough progress that I was willing to try it out. 9 | 10 | This may only work with the newer models, which are white. I don't have the older orange card to test with. 11 | 12 | The EZShare is currently made in two varieties -- one is an actual SD card (in various capacities) with wireless built in. 13 | The other is an adapter for a microSD card. 14 | 15 | I purchased a couple of the adapter versions for ~$20 each on Ali Express (item #2251832428648118), but you can get them from other places, too. 16 | (I figure the adapter version with name brand memory purchased from a trusted seller might be more reliable than the ones with built in memory.) 17 | 18 | After a fair amount of messing around with the card, and the notes from the other fellow's abandoned attempt, I was able to put a shell script together that sort of worked late last year. 19 | I played with it a bit but it wasn't very reliable and I shelved it. 20 | 21 | I need to learn Python for my new job, and I decided to dig it out as a project to familarize myself with the language while potentially creating something useful. 22 | 23 | I hope it works for you! 24 | 25 | PS - Feel free to fork this if you know the file structure for other high-quality CPAP/BiPAP devices that log meaningful data to the card. 26 | https://www.apneaboard.com/wiki/index.php?title=OSCAR_supported_machines 27 | -------------------------------------------------------------------------------- /install_ezshare.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | create_venv() { 4 | local env_name=${1:-".venv"} 5 | 6 | python3 -m venv $env_name 7 | } 8 | 9 | install_deps() { 10 | local env_name=${1:-".venv"} 11 | 12 | source $env_name/bin/activate 13 | 14 | if [ -f "requirements.txt" ]; then 15 | pip install -r ./requirements.txt 16 | fi 17 | 18 | if [ -f "setup.py" ]; then 19 | pip install -e . 20 | fi 21 | } 22 | 23 | check_venv() { 24 | local env_name=${1:-".venv"} 25 | 26 | if [ -d "$env_name" ]; then 27 | true 28 | else 29 | false 30 | fi 31 | } 32 | 33 | check_python() { 34 | if command -v python3 > /dev/null 2>&1 ; then 35 | true 36 | else 37 | false 38 | fi 39 | } 40 | 41 | venv_name=$HOME/.venv/ezshare_resmed 42 | if ! check_python ; then 43 | echo "Python3 not installed, please install" 44 | exit 1 45 | fi 46 | 47 | if ! check_venv $venv_name ; then 48 | create_venv $venv_name 49 | fi 50 | 51 | install_deps $venv_name 52 | mkdir -p $HOME/.local/bin 53 | mkdir -p $HOME/.config/ezshare_resmed 54 | cp ezshare_resmed $HOME/.local/bin 55 | cp ezshare_resmed.py $HOME/.local/bin 56 | if ! [ -f "$HOME/.config/ezshare_resmed/config.ini" ]; then 57 | cp ezshare_resmed_default.ini $HOME/.config/ezshare_resmed/config.ini 58 | fi 59 | 60 | chmod +x $HOME/.local/bin/ezshare_resmed 61 | chmod +x $HOME/.local/bin/ezshare_resmed.py 62 | 63 | if ! [[ ":$PATH:" == *":$HOME/.local/bin:"* ]]; then 64 | echo -e "\nezshare_resmed is installed to $HOME/.local/bin which is not in your PATH" 65 | echo "Add \$HOME/.local/bin to your PATH before running ezshare_resmed" 66 | 67 | shell=$(basename $SHELL) 68 | if [[ $shell == "zsh" ]]; then 69 | echo "Your shell appears to be zsh. To set the PATH in zsh run:" 70 | echo -e "\necho 'export PATH="\$HOME/.local/bin:\$PATH"' >> ~/.zshrc" 71 | echo "source ~/.zshrc" 72 | elif [[ $shell == "bash" ]]; then 73 | echo "Your shell appears to be bash. To set the PATH in bash run:" 74 | echo -e "\necho 'export PATH="\$HOME/.local/bin:\$PATH"' >> ~/.bashrc" 75 | echo "source ~/.bashrc" 76 | fi 77 | fi 78 | 79 | echo -e "\nInstallation complete" 80 | echo "Default configuration file is $HOME/.config/ezshare_resmed/config.ini" 81 | echo "Run with:" 82 | echo -e "\nezshare_resmed" 83 | -------------------------------------------------------------------------------- /ezshare_generic.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | import time 4 | import platform 5 | from subprocess import run, PIPE 6 | import urllib.parse 7 | from bs4 import BeautifulSoup 8 | 9 | # Configurations 10 | root_path = os.path.join(os.path.expanduser('~'), "Documents", "CPAP_Data", "SD_card") 11 | USE_NETWORK_SWITCHING = True 12 | EZSHARE_NETWORK = "airsense11" 13 | EZSHARE_PASSWORD = "5742104979" 14 | CONNECTION_DELAY = 5 15 | root_url = 'http://192.168.4.1/dir?dir=A:' 16 | 17 | 18 | def get_files_and_dirs(url): 19 | html_content = requests.get(url) 20 | soup = BeautifulSoup(html_content.text, 'html.parser') 21 | files, dirs = [], [] 22 | 23 | for link in soup.find_all('a', href=True): 24 | link_text = link.text.strip() 25 | link_href = link['href'] 26 | if link_text not in ['.', '..', 'System Volume Information', 'back to photo']: 27 | if 'download?file' in link_href: 28 | files.append((link_text, urllib.parse.urlparse(link_href).query)) 29 | elif 'dir?dir' in link_href: 30 | dirs.append((link_text, link_href)) 31 | return files, dirs 32 | 33 | 34 | def download_file(url, filename, retries=3): 35 | for _ in range(retries): 36 | try: 37 | response = requests.get(url) 38 | with open(filename, 'wb') as file: 39 | file.write(response.content) 40 | print(f'{filename} completed (V)') 41 | return 42 | except requests.exceptions.RequestException as e: 43 | print(f"Error downloading {url}: {e}. Retrying...") 44 | time.sleep(1) 45 | 46 | 47 | def process_dirs(dirs, url, dir_path): 48 | for dirname, dir_url in dirs: 49 | new_dir_path = os.path.join(dir_path, dirname) 50 | os.makedirs(new_dir_path, exist_ok=True) 51 | absolute_dir_url = urllib.parse.urljoin(url, dir_url) 52 | controller(absolute_dir_url, new_dir_path) 53 | 54 | 55 | def process_files(files, url, dir_path): 56 | for filename, file_url in files: 57 | local_path = os.path.join(dir_path, filename) 58 | absolute_file_url = urllib.parse.urljoin(url, f'download?{file_url}') 59 | download_file(absolute_file_url, local_path) 60 | 61 | 62 | def controller(url, dir_path): 63 | files, dirs = get_files_and_dirs(url) 64 | process_files(files, url, dir_path) 65 | process_dirs(dirs, url, dir_path) 66 | 67 | 68 | # Only for MacOS 69 | def connect_to_wifi(ssid, password=None): 70 | cmd = f'networksetup -setairportnetwork en0 {ssid} {password or ""}' 71 | result = run(cmd, shell=True, stdout=PIPE, stderr=PIPE) 72 | if result.returncode == 0: 73 | print(f"Connected to {ssid} successfully.") 74 | return True 75 | else: 76 | print(f"Failed to connect to {ssid}. Error: {result.stderr.decode('utf-8')}") 77 | return False 78 | 79 | 80 | # Execution Block 81 | if USE_NETWORK_SWITCHING: 82 | print(f"Connecting to {EZSHARE_NETWORK}. Waiting a few seconds for connection to establish...") 83 | if connect_to_wifi(EZSHARE_NETWORK, EZSHARE_PASSWORD): 84 | time.sleep(CONNECTION_DELAY) 85 | else: 86 | print("Connection attempt canceled by user.") 87 | exit(0) 88 | 89 | controller(root_url, root_path) 90 | -------------------------------------------------------------------------------- /ReadMe-Generic.txt: -------------------------------------------------------------------------------- 1 | This script assists in using a WiFi enabled SD card by EzShare. For Resmed devices, use the ResMed specific version for more options. 2 | Please feel free to create versions supporting Philips and other manufacturers - I wasn't able to get a card from anything but resmed. 3 | 4 | Most of the program is platform-independent, but there's a bit of extra convenience built in for Mac users. 5 | 6 | The program runs on Python 3, and requires the Requests and Beautiful Soup 4 libraries. 7 | 8 | Since I don't know anything about the SD structure of other manufacturers, this version will overwrite everything, every time, so 9 | it may get to be time consuming if you have a lot of data. You can set an import cutoff date in OSCAR to at least save some time there. 10 | 11 | #################################################################################################### 12 | Python & Libraries installation: 13 | #################################################################################################### 14 | Download and install Python 3 from the official website: https://www.python.org/downloads/ 15 | Make sure to check the option to add Python to PATH during the installation process. 16 | (Mac users may prefer to use the HomeBrew* package manager) 17 | 18 | Open Terminal (Mac)/ Command Prompt (Windows) and run the following command to install the additional required libraries: 19 | 20 | cd CPAP-data-from-EZShare-SD 21 | pip install -r requirements.txt 22 | 23 | ### Alternate MacOS instructions using HomeBrew (run commands in Terminal): 24 | Skip first line of HomeBrew is already installed. 25 | 26 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 27 | brew install python 28 | cd CPAP-data-from-EZShare-SD 29 | pip install -r requirements.txt 30 | 31 | ################################################################################################# 32 | Data Location: 33 | ################################################################################################# 34 | 35 | The default code (os.path.join etc) will place the file in the path below. 36 | Windows: C:\Users\MY_USERNAME\Documents\CPAP_Data 37 | MacOS: /Users/MY_USERNAME/Documents/CPAP_Data 38 | Linux et al: /home/MY_USERNAME/Documents/CPAP_Data 39 | 40 | You may need to create a directory named CPAP_Data in your Documents folder. 41 | 42 | You can store it wherever you want to, as long as OSCAR can read from it. Just modify the configuration block with the location you prefer. 43 | 44 | 45 | #################################################################################################### 46 | EZCard Setup 47 | #################################################################################################### 48 | By default, EzCard creates a wifi network named "Ez Card" with a password of 88888888 (that's eight eights) 49 | You can change the network name and password via the card's web interface: 50 | http://ezshare.card/config?vtype=0 (default card admin password is "admin"). 51 | 52 | If necessary, deleting the ezshare.cfg file will change the network information back to the default. 53 | 54 | EZSHARE_NETWORK = "Ez Card" 55 | EZSHARE_PASSWORD = "88888888" 56 | 57 | 58 | ################################################################################################# 59 | Retrieving files from the card 60 | ################################################################################################# 61 | This may be called from its folder directly, with or without arguments to overwrite the defaults: 62 | python ezshare_generic.py 63 | 64 | ################################################################################################# 65 | Use with OSCAR 66 | ################################################################################################# 67 | It's very easy to use this with OSCAR. On the Welcome tab, simply click on the CPAP importer icon (looks like an SD card) and navigate to the folder specified in the data location configuration. It will likely save that location and use it going forward. 68 | -------------------------------------------------------------------------------- /ReadMe.txt: -------------------------------------------------------------------------------- 1 | This script assists in using a WiFi enabled SD card by EzShare in your CPAP/BiPap device. 2 | This is coded for use with most ResMed devices from version 9 and up. 3 | Feel free to fork it for use with Philips Respironics and other devices. 4 | 5 | Most of the program is platform-independent, but there's a bit of extra convenience built in for Mac users. 6 | 7 | The program runs on Python 3, and requires the Requests and Beautiful Soup 4 libraries. 8 | 9 | #################################################################################################### 10 | Python & Libraries installation: 11 | #################################################################################################### 12 | Download and install Python 3 from the official website: https://www.python.org/downloads/ 13 | Make sure to check the option to add Python to PATH during the installation process. 14 | (Mac users may prefer to use the HomeBrew* package manager) 15 | 16 | Open Terminal (Mac)/ Command Prompt (Windows) and run the following command to install the additional required libraries: 17 | 18 | cd CPAP-data-from-EZShare-SD 19 | python3 -m venv .venv 20 | source .venv/bin/activate 21 | pip install -r requirements.txt 22 | 23 | ### Alternate MacOS instructions using HomeBrew (run commands in Terminal): 24 | Skip first line of HomeBrew is already installed. 25 | 26 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 27 | brew install python 28 | cd CPAP-data-from-EZShare-SD 29 | pip install -r requirements.txt 30 | 31 | ################################################################################################# 32 | Data Location: 33 | ################################################################################################# 34 | 35 | The default code (os.path.join etc) will place the file in the path below. 36 | Windows: C:\Users\MY_USERNAME\Documents\CPAP_Data 37 | MacOS: /Users/MY_USERNAME/Documents/CPAP_Data 38 | Linux et al: /home/MY_USERNAME/Documents/CPAP_Data 39 | 40 | You may need to create a directory named CPAP_Data in your Documents folder. 41 | 42 | You can store it wherever you want to, as long as OSCAR can read from it. Just modify the configuration block with the location you prefer. 43 | 44 | 45 | ################################################################################################# 46 | Configuration options & defaults 47 | ################################################################################################# 48 | 49 | START_FROM -- This has three options: 50 | 1) an integer indicating the number of days of history to download 51 | 2) a YYYYMMDD date indicating which date to start from 52 | 3) A string, 'ALL', removing date restrictions and downloading all available data 53 | 54 | OVERWRITE -- This has two options: 55 | False - Don't overwrite any of the date-specific files. (other files must always be overwritten) 56 | True - delete and replace. This is mostly useful if you either accidentally deleted a partial date in OSCAR, or if you ran this and then went back to sleep before noon and wanted to ensure that the full day was captured 57 | 58 | SHOW_PROGRESS -- This has three options: 59 | False - Shows fairly minimal output 60 | True - Shows date folder output 61 | Verbose - Shows what happens to every file 62 | 63 | 64 | #################################################################################################### 65 | EZCard Setup 66 | #################################################################################################### 67 | By default, EzCard creates a wifi network named "Ez Card" with a password of 88888888 (that's eight eights) 68 | You can change the network name and password via the card's web interface: 69 | http://ezshare.card/config?vtype=0 (default card admin password is "admin"). 70 | 71 | If necessary, deleting the ezshare.cfg file will change the network information back to the default. 72 | 73 | EZSHARE_NETWORK = "Ez Card" 74 | EZSHARE_PASSWORD = "88888888" 75 | 76 | 77 | ################################################################################################# 78 | Retrieving files from the card 79 | ################################################################################################# 80 | This may be called from its folder directly, with or without arguments to overwrite the defaults: 81 | python ezshare_resmed.py 82 | python ezshare_resmed.py --start_from 20230101 --show_progress Verbose --overwrite 83 | It may also be called from a shell script, so you can put that on your desktop 84 | while keeping the python code in a less accessible location: 85 | ./run_foo.sh 86 | ./run_foo.sh --start_from 20230101 --show_progress Verbose --overwrite 87 | 88 | ################################################################################################# 89 | Use with OSCAR 90 | ################################################################################################# 91 | It's very easy to use this with OSCAR. On the Welcome tab, simply click on the CPAP importer icon (looks like an SD card) and navigate to the folder specified in the data location configuration. It will likely save that location and use it going forward. 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EzShare ResMed 2 | This script assists in using a WiFi enabled SD card by EzShare in your CPAP/BiPap device. This is coded for use with most ResMed devices from version 9 and up. Feel free to fork it for use with Philips Respironics and other devices. 3 | 4 | The program runs on Python 3, and requires dependencies to be installed. Python versions 3.9 to 3.12 have been tested. 5 | 6 | ## Usage 7 | 8 | ### Options 9 | 10 | | Argument | Description | 11 | | --- | --- | 12 | | `-h`, `--help` | show this help message and exit | 13 | | `--path PATH` | set destination path, defaults to $HOME/Documents/CPAP_Data/SD_card | 14 | | `--url URL` | set source URL, Defaults to http://192.168.4.1/dir?dir=A: | 15 | | `--start_from START_FROM` | start from date in YYYYMMDD format, deaults to None; this will override day_count if set | 16 | | `--day_count DAY_COUNT`, `-n DAY_COUNT` | number of days to sync, defaults to None; if both start_from and day_count are unset all files will be synced | 17 | | `--show_progress` | show progress, defaults to True | 18 | | `--verbose`, `-v` | verbose output, defaults to False | 19 | | `--overwrite` | force overwriting existing files, defaults to False | 20 | | `--keep_old` | do not overwrite even if newer version is available, defaults to False | 21 | | `--ignore IGNORE` | case insensitive comma separated list (no spaces) of files to ignore, defaults to JOURNAL.JNL,ezshare.cfg,System Volume Information | 22 | | `--ssid SSID` | set network SSID; WiFi connection will be attempted if set, defaults to ez Share | 23 | | `--psk PSK` | set network pass phrase, defaults to 88888888 | 24 | | `--retries RETRIES` | set number of retries for failed downloads, defaults to 5 | 25 | | `--version` | show program's version number and exit | 26 | 27 | 28 | ### Example 29 | ezshare_resmed --ssid ezshare --psk 88888888 --show_progress 30 | 31 | ### Data Save Location 32 | - Windows: `C:\Users\\Documents\CPAP_Data` 33 | - macOS: `/Users//Documents/CPAP_Data` 34 | - Linux: `/home//Documents/CPAP_Data` 35 | 36 | ## Configuration 37 | Configuration to set the default parameters is done with a `config.ini` file. 38 | 39 | ### Example `config.ini` 40 | ``` 41 | [ezshare_resmed] 42 | path = ~/Documents/CPAP_Data/SD_card 43 | url = http://192.168.4.1/dir?dir=A: 44 | start_from = 20230924 45 | day_count = 5 46 | show_progress = True 47 | verbose = False 48 | overwrite = False 49 | keep_old = False 50 | ignore = JOURNAL.JNL,ezshare.cfg,System Volume Information 51 | ssid = ez Share 52 | psk = 88888888 53 | retries = 5 54 | ``` 55 | 56 | ### Configuration file locations 57 | ezshare_resmed looks for config files in this order: 58 | - `./ezshare_resmed.ini` - in the same directory as the script 59 | - `./config.ini` - in the same directory as the script 60 | - `~/.config/ezshare_resmed.ini` 61 | - `~/.config/ezshare_resmed/ezshare_resmed.ini` 62 | - `~/.config/ezshare_resmed/config.ini` 63 | 64 | 65 | ## Setup 66 | 1. [Install Python 3](#install-python-3) 67 | 2. Download repository 68 | 2. [Run installer script](#install-ezshare_resmed) 69 | 70 | ### Install ezshare_resmed 71 | - [Install on Windows](#winndows-setup) 72 | - [Install on macOS/Linux](#macoslinux-setup) 73 | 74 | #### Winndows Setup 75 | 1. Open command window 76 | 2. Run: `cd CPAP-data-from-EZShare-SD` 77 | 3. Run: `install_ezshare.bat` 78 | 4. The program, ezshare_resmed, is installed in `%USERPROFILE%\.local\bin` which will be added to the user `%PATH%` if it was not already, in which case a new command window will need to be opened 79 | 5. Run: `ezshare_resmed` 80 | 81 | #### macOS/Linux Setup 82 | 1. Open your **Terminal** application 83 | 2. Run: `cd CPAP-data-from-EZShare-SD` 84 | 3. Run: `./install_ezshare.sh` 85 | 4. The program, ezshare_resmed, is installed in `$HOME/.local/bin`, if it is not already in the `$PATH` run: 86 | - **bash**: `echo 'export PATH="\$HOME/.local/bin:\$PATH"' >> ~/.bashrc && source ~/.bashrc` 87 | - **zsh**: `echo 'export PATH="\$HOME/.local/bin:\$PATH"' >> ~/.zshrc && source ~/.zshrc` 88 | 5. Run: `ezshare_resmed` 89 | 90 | 91 | ### Install Python 3 92 | A Quick Guide for Installing Python 3 on Common Operating Systems 93 | 94 | - [Install on Windows](#windows) 95 | - [Install on macOS](#macos) 96 | - [Install on Linux](#linux) 97 | 98 | #### Windows 99 | 1. Open a command window, Run: `winget install -e --id Python.Python.3.12` 100 | 101 | 2. Once Python is installed, you should be able to open a command window, type `python`, hit ENTER, and see a Python prompt opened. Type `quit()` to exit it. You should also be able to run the command `pip` and see its options. If both of these work, then you are ready to go. 102 | - If you cannot run `python` or `pip` from a command prompt, you may need to add the Python installation directory path to the Windows PATH variable 103 | - The easiest way to do this is to find the new shortcut for Python in your start menu, right-click on the shortcut, and find the folder path for the `python.exe` file 104 | - For Python3, this will likely be something like `C:\Users\\AppData\Local\Programs\Python\Python312` 105 | - Open your Advanced System Settings window, navigate to the "Advanced" tab, and click the "Environment Variables" button 106 | - Create a new system variable: 107 | - Variable name: `PYTHON_HOME` 108 | - Variable value: 109 | - Now modify the PATH system variable by appending the text `;%PYTHON_HOME%\;%PYTHON_HOME%;%PYTHON_HOME%\Scripts\` to the end of it. 110 | - Close out your windows, open a command window and make sure you can run the commands `python` and `pip` 111 | 112 | #### macOS 113 | macOS comes with a native version of Python but it is not recommended to use the native Python in order to not alter the system environment. There are a couple of ways we can install Python3 but this script is only tested using Homebrew. 114 | 115 | ##### Install with Homebrew 116 | [Homebrew](https://brew.sh/) is a MacOS Linux-like package manager. Walk through the below steps to install Homebrew and an updated Python interpreter along with it. 117 | 118 | 1. Open your **Terminal** application and run: `xcode-select --install`. This will open a window. Click **'Get Xcode'** and install it from the app store. 119 | 2. Install Homebrew. Run: `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"` 120 | - You can also find this command on the [Homebrew website](https://brew.sh/) 121 | 3. Install latest Python3 with `brew install python` 122 | 4. Once Python is installed, you should be able to open your **Terminal** application, type `python3`, hit ENTER, and see a Python 3.X.X prompt opened. Type `quit()` to exit it. You should also be able to run the command `pip3` and see its options. If both of these work, then you are ready to go. 123 | - Here are some additional resources on [Installing Python 3 on Mac OS X](https://docs.python-guide.org/starting/install3/osx/) 124 | 125 | #### Linux 126 | - **Raspberry Pi OS** may need Python and PIP 127 | - Install them: `sudo apt install -y python3-pip` 128 | - **Debian (Ubuntu)** distributions may need Python and PIP 129 | - Update the list of available APT repos with `sudo apt update` 130 | - Install Python and PIP: `sudo apt install -y python3-pip` 131 | - **RHEL (CentOS)** distributions usually need PIP 132 | - Install the EPEL package: `sudo yum install -y epel-release` 133 | - Install PIP: `sudo yum install -y python3-pip` 134 | - **Arch** may need Python and PIP 135 | - Refresh pacman database and update system: `sudo pacman -Syu` 136 | - Install PIP: `sudo pacman -S python python-pip` -------------------------------------------------------------------------------- /ezshare_resmed.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import pathlib 4 | import time 5 | import sys 6 | import platform 7 | import argparse 8 | import subprocess 9 | import urllib.parse 10 | import datetime 11 | import configparser 12 | import re 13 | import logging 14 | import textwrap 15 | import shutil 16 | import tempfile 17 | 18 | import bs4 19 | import requests 20 | from requests import adapters 21 | import tqdm 22 | from urllib3.util import retry 23 | 24 | 25 | APP_NAME = pathlib.Path(__file__).stem 26 | VERSION = 'v1.0.2-beta' 27 | logger = logging.getLogger(APP_NAME) 28 | 29 | 30 | class EZShare(): 31 | """ 32 | Class to handle the EZShare SD card download process 33 | 34 | Attributes: 35 | path (str): Local path to store the downloaded files 36 | url (str): URL of the EZShare SD card 37 | start_time (datetime.datetime): Date to start syncing from 38 | show_progress (bool): Print progress 39 | overwrite (bool): Overwrite existing files 40 | keep_old (bool): Do not overwrite even if newer version is available 41 | ssid (str): SSID of the network to connect to 42 | psk (str): Passphrase of the network to connect to 43 | connection_id (str): Connection ID of the network connection 44 | existing_connection_id (str): Connection ID of the existing network connection 45 | interface_name (str): Name of the Wi-Fi interface 46 | platform_system (str): platform.system() 47 | session (requests.Session): Session object for the requests library 48 | ignore (list[str]): List of files to ignore 49 | retries (retry.Retry): Retry object for the requests library 50 | connection_delay (int): Delay in seconds after connecting to the network 51 | 52 | Methods: 53 | print: Check if messages should be printed and prints 54 | connect_to_wifi: Connect to the EZShare network 55 | run: Entry point for the EZShare class 56 | recursive_traversal: Recursivly traverse the file system 57 | list_dir: List files and directories in the current directory 58 | check_files: Determine if files should be downloaded or skipped and downloads the correct files 59 | download_file: Grab a single file from the SD card 60 | check_dirs: Determine if folders should be included or skipped, create new folders where necessary 61 | should_process_folder: Checks that datalog files are within sync range 62 | disconnect_from_wifi: Disconnect from the EZShare network 63 | """ 64 | 65 | def __init__(self, path, url, start_time, show_progress, verbose, 66 | overwrite, keep_old, ssid, psk, ignore, retries=5, 67 | connection_delay=5, debug=False): 68 | """ 69 | Class constructor for the EZShare class 70 | 71 | Args: 72 | path (str): Local path to store the downloaded files 73 | url (str): URL of the EZShare SD card 74 | start_time (datetime.datetime): Date to start syncing from 75 | show_progress (bool): Print progress 76 | verbose (bool): If verbose output should be shown 77 | overwrite (bool): Overwrite existing files 78 | keep_old (bool): Do not overwrite even if newer version is available 79 | ssid (str): SSID of the network to connect to 80 | psk (str): Passphrase of the network to connect to 81 | ignore (list[str]): List of files to ignore 82 | retries (int): Number of retries for failed downloads, defaults to 5 83 | connection_delay (int): Delay in seconds after connecting to the network, defaults to 5 84 | debug (bool): Sets log level to DEBUG, defaults to False 85 | """ 86 | if debug: 87 | log_level = logging.DEBUG 88 | elif verbose: 89 | log_level = logging.INFO 90 | else: 91 | log_level = logging.WARN 92 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 93 | level=log_level) 94 | self.path = pathlib.Path(path).expanduser() 95 | self.url = url 96 | self.start_time = start_time 97 | self.show_progress = show_progress 98 | self.overwrite = overwrite 99 | self.keep_old = keep_old 100 | self.ssid = ssid 101 | self.psk = psk 102 | self.connection_id = None 103 | self.existing_connection_id = None 104 | self.platform_system = platform.system() 105 | self.interface_name = None 106 | self.connected = False 107 | self.session = requests.Session() 108 | self.ignore = ['.', '..', 'back to photo'] + ignore 109 | self.retries = retries 110 | self.retry = retry.Retry(total=retries, backoff_factor=0.25) 111 | self.connection_delay = connection_delay 112 | self.session.mount('http://', adapters.HTTPAdapter(max_retries=self.retry)) 113 | 114 | @property 115 | def wifi_profile(self): 116 | """ 117 | str: Windows WiFi profile formatted in XML for the specified SSID and password 118 | """ 119 | if self.ssid and self.psk: 120 | return textwrap.dedent(f"""\ 121 | 122 | 123 | {self.ssid}_script_profile 124 | 125 | 126 | {self.ssid} 127 | 128 | 129 | ESS 130 | manual 131 | 132 | 133 | 134 | WPA2PSK 135 | AES 136 | false 137 | 138 | 139 | passPhrase 140 | false 141 | {self.psk} 142 | 143 | 144 | 145 | 146 | false 147 | 148 | 149 | """) 150 | elif self.ssid: 151 | return textwrap.dedent(f"""\ 152 | 153 | 154 | {self.ssid}_script_profile 155 | 156 | 157 | {self.ssid} 158 | 159 | 160 | ESS 161 | auto 162 | 163 | 164 | 165 | open 166 | none 167 | false 168 | 169 | 170 | 171 | 172 | false 173 | 174 | 175 | """) 176 | else: 177 | return None 178 | 179 | def print(self, message): 180 | """ 181 | Checks if progress should be shown and if so prints the message 182 | 183 | Args: 184 | message (str): message to print 185 | """ 186 | if self.show_progress: 187 | print(message) 188 | 189 | def connect_to_wifi(self): 190 | """ 191 | Wifi Connect - Connect to EZShare Wi-Fi network specified in ssid 192 | 193 | Raises: 194 | RuntimeError: When automatically connecting to WiFi is not supported on 195 | the system or it fails to connect 196 | """ 197 | if self.platform_system == 'Darwin': 198 | self.connect_to_wifi_macos() 199 | elif self.platform_system == 'Linux' and self.has_network_manager(): 200 | self.connect_to_wifi_linux() 201 | elif self.platform_system == 'Windows': 202 | self.connect_to_wifi_windows() 203 | else: 204 | raise RuntimeError('Automatic Wi-Fi connection is not supported on this system.') 205 | 206 | self.print(f'Connected to {self.ssid} successfully.') 207 | 208 | def connect_to_wifi_macos(self): 209 | """ 210 | Wifi Connect - macOS logic for WiFi 211 | 212 | Raises: 213 | RuntimeError: When automatically connecting to WiFi fails 214 | """ 215 | get_interface_cmd = 'networksetup -listallhardwareports' 216 | try: 217 | get_interface_result = subprocess.run(get_interface_cmd, 218 | shell=True, 219 | capture_output=True, 220 | text=True, check=True) 221 | except subprocess.CalledProcessError as e: 222 | raise RuntimeError(f'Error getting Wi-Fi interface name. Return code: {e.returncode}, error: {e.stderr}') from e 223 | 224 | interface_lines = get_interface_result.stdout.split('\n') 225 | for index, line in enumerate(interface_lines): 226 | if 'Wi-Fi' in line: 227 | self.interface_name = interface_lines[index + 1].split(':')[1].strip() 228 | break 229 | if not self.interface_name: 230 | raise RuntimeError('No Wi-Fi interface found') 231 | 232 | connect_cmd = f'networksetup -setairportnetwork {self.interface_name} "{self.ssid}"' 233 | if self.psk: 234 | connect_cmd += f' "{self.psk}"' 235 | try: 236 | connect_result = subprocess.run(connect_cmd, shell=True, 237 | capture_output=True, 238 | text=True, check=True) 239 | except subprocess.CalledProcessError as e: 240 | raise RuntimeError(f'Error connecting to {self.ssid}. Return code: {e.returncode}, error: {e.stderr}') from e 241 | if connect_result.stdout.startswith('Failed to join network'): 242 | raise RuntimeError(f'Error connecting to {self.ssid}. Error: {connect_result.stdout}') 243 | self.connection_id = self.ssid 244 | self.connected = True 245 | 246 | def connect_to_wifi_linux(self): 247 | """ 248 | Wifi Connect - Linux logic for WiFi 249 | 250 | Raises: 251 | RuntimeError: When automatically connecting to WiFi fails 252 | """ 253 | if self.psk: 254 | connect_cmd = f'nmcli d wifi connect "{self.ssid}" password "{self.psk}"' 255 | else: 256 | connect_cmd = f'nmcli connection up "{self.ssid}"' 257 | try: 258 | connect_result = subprocess.run(connect_cmd, shell=True, 259 | capture_output=True, text=True, 260 | check=True) 261 | except subprocess.CalledProcessError as e: 262 | self.connection_id = self.ssid 263 | raise RuntimeError(f'Error connecting to {self.ssid}. Return code: {e.returncode}, error: {e.stderr}') from e 264 | 265 | # Regular expression pattern to match the string after "activated with" 266 | pattern = r"activated with '([^']*)'" 267 | 268 | # Search for the pattern in the message 269 | match = re.search(pattern, connect_result.stdout) 270 | 271 | if match: 272 | # Extract the string after "activated with" 273 | self.connection_id = match.group(1) 274 | self.connected = True 275 | 276 | def connect_to_wifi_windows(self): 277 | """ 278 | Wifi Connect - Windows logic for WiFi 279 | 280 | Raises: 281 | RuntimeError: When automatically connecting to WiFi fails 282 | """ 283 | existing_profile_cmd = 'netsh wlan show interfaces' 284 | try: 285 | existing_profile_result = subprocess.run(existing_profile_cmd, 286 | shell=True, 287 | capture_output=True, 288 | text=True, check=True) 289 | except subprocess.CalledProcessError as e: 290 | raise RuntimeError(f'Error checking network existing network profile. Return code: {e.returncode}, error: {e.stderr}') from e 291 | for line in existing_profile_result.stdout.split('\n'): 292 | if line.strip().startswith('Profile'): 293 | self.existing_connection_id = line.split(':')[1].strip() 294 | break 295 | 296 | with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', 297 | delete=False) as wifi_profile_file: 298 | wifi_profile_file.write(self.wifi_profile) 299 | temp_profile_filename = wifi_profile_file.name 300 | profile_cmd = f'netsh wlan add profile filename={wifi_profile_file.name}' 301 | try: 302 | subprocess.run(profile_cmd, shell=True, capture_output=True, 303 | text=True, check=True) 304 | except subprocess.CalledProcessError as e: 305 | raise RuntimeError(f'Error creating network profile for {self.ssid}. Return code: {e.returncode}, error: {e.stderr}') from e 306 | finally: 307 | os.remove(temp_profile_filename) 308 | connection_id = f'{self.ssid}_script_profile' 309 | connect_cmd = f'netsh wlan connect name="{connection_id}"' 310 | try: 311 | subprocess.run(connect_cmd, shell=True, capture_output=True, 312 | text=True, check=True) 313 | except subprocess.CalledProcessError as e: 314 | raise RuntimeError(f'Error connecting to {self.ssid}. Return code: {e.returncode}, error: {e.stderr}') from e 315 | self.connection_id = connection_id 316 | 317 | def wifi_connected(self): 318 | """ 319 | Checks if WiFi is connected 320 | 321 | Returns: 322 | bool: True if connected to specified WiFi network, False if not 323 | """ 324 | if self.connected: 325 | return True 326 | if self.platform_system == 'Windows': 327 | existing_profile_cmd = 'netsh wlan show interfaces' 328 | try: 329 | current_profile_result = subprocess.run(existing_profile_cmd, 330 | shell=True, 331 | capture_output=True, 332 | text=True, check=True) 333 | except subprocess.CalledProcessError as e: 334 | raise RuntimeError(f'Error checking network existing network profile. Return code: {e.returncode}, error: {e.stderr}') from e 335 | for line in current_profile_result.stdout.split('\n'): 336 | if line.strip().startswith('Profile'): 337 | if line.split(':')[1].strip() == self.connection_id: 338 | self.connected = True 339 | return True 340 | else: 341 | return False 342 | 343 | def has_network_manager(self): 344 | """ 345 | Checks if nmcli is present on the system and if it can manage the WiFi network 346 | 347 | Returns: 348 | bool: True if NetworkManager can manage WiFi, False if ncmcli is 349 | missing, NetworkManager is not active, wifi device is not present, 350 | wifi devicis not in a connected or disconnected status, or nmcli 351 | is unable to list networks available on the wifi device 352 | """ 353 | if shutil.which('nmcli') is None: 354 | return False 355 | 356 | network_manager_is_active_cmd = 'systemctl is-active --quiet NetworkManager' 357 | try: 358 | subprocess.run(network_manager_is_active_cmd, shell=True, 359 | capture_output=True, text=True, check=True) 360 | except subprocess.CalledProcessError: 361 | return False 362 | 363 | get_device_cmd = 'nmcli device status' 364 | get_device_result = subprocess.run(get_device_cmd, shell=True, 365 | capture_output=True, text=True, 366 | check=True) 367 | for line in get_device_result.stdout.split('\n'): 368 | if 'wifi' in line and 'wifi-p2p' not in line: 369 | self.interface_name = line.split()[0] 370 | break 371 | if self.interface_name is None: 372 | return False 373 | 374 | managed_cmd = f'nmcli device show "{self.interface_name}"' 375 | managed_result = subprocess.run(managed_cmd, shell=True, 376 | capture_output=True, text=True, 377 | check=True) 378 | for line in managed_result.stdout.split('\n'): 379 | if line.startswith('GENERAL.STATE:'): 380 | if 'connected' not in line: 381 | self.interface_name = None 382 | return False 383 | break 384 | 385 | nmcli_works_cmd = f'nmcli device wifi list ifname "{self.interface_name}"' 386 | try: 387 | subprocess.run(nmcli_works_cmd, shell=True, capture_output=True, 388 | text=True, check=True) 389 | except subprocess.CalledProcessError: 390 | self.interface_name = None 391 | return False 392 | 393 | return True 394 | 395 | def run(self): 396 | """ 397 | Entry point for the EZShare class 398 | 399 | Raises: 400 | SystemExit: When the path does not exist and create_missing is False 401 | """ 402 | if self.ssid: 403 | self.print(f'Connecting to {self.ssid}.') 404 | try: 405 | self.connect_to_wifi() 406 | except RuntimeError as e: 407 | logger.warning('Failed to connect to %s. Error: %s', self.ssid, str(e)) 408 | else: 409 | self.print('Waiting a few seconds for connection to establish...') 410 | time.sleep(self.connection_delay) 411 | 412 | if not self.wifi_connected(): 413 | if sys.__stdin__.isatty(): 414 | response = input('Unable to connect automatically, please connect manually and press "C" to continue or any other key to cancel: ') 415 | if response.lower() != 'c': 416 | sys.exit('Cancled') 417 | else: 418 | logger.warning('No Wi-Fi connection was estableshed. Attempting to continue...') 419 | 420 | try: 421 | self.path.mkdir(parents=True, exist_ok=True) 422 | except FileExistsError: 423 | sys.exit(f'Path {self.path} already exists and is a file. Unable to continue.') 424 | 425 | self.recursive_traversal(self.url, self.path) 426 | 427 | def recursive_traversal(self, url, dir_path): 428 | """ 429 | Recursivly traverse the file system 430 | 431 | Args: 432 | url (str): URL of the directory to traverse 433 | dir_path (pathlib.Path): Local path of the directory to traverse 434 | """ 435 | files, dirs = self.list_dir(url) 436 | self.check_files(files, url, dir_path) 437 | self.check_dirs(dirs, url, dir_path) 438 | 439 | def list_dir(self, url): 440 | """ 441 | Lists names and links to files and directories in the referenced directory 442 | 443 | Args: 444 | url (str): URL to the directory to be listed 445 | 446 | Returns: 447 | Tuple[list,list]: 448 | [0] (list[Tuple[str,str,float]]): A list containing a tuple for 449 | each file in the current directory 450 | [0] (str): Name of the file 451 | [1] (str): URL component to the file 452 | [2] (float): Modification time of the file as a POSIX timestamp 453 | [1] (list[Tuple[str,str]]): A list containing a tuple for each 454 | directory in the directory in the current directory 455 | [0] (str): Name of the directory 456 | [1] (str): URL component to the directory 457 | """ 458 | html_content = requests.get(url, timeout=5) 459 | soup = bs4.BeautifulSoup(html_content.text, 'html.parser') 460 | files = [] 461 | dirs = [] 462 | 463 | pre_text = soup.find('pre').decode_contents() 464 | lines = pre_text.split('\n') 465 | 466 | for line in lines: 467 | if line.strip(): # Skip empty line 468 | parts = line.rsplit(maxsplit=2) 469 | modifypart = parts[0].replace('- ', '-0').replace(': ', ':0') 470 | regex_pattern = r'\d*-\d*-\d*\s*\d*:\d*:\d*' 471 | 472 | match = re.search(regex_pattern, modifypart) 473 | 474 | if match: 475 | file_ts = datetime.datetime.strptime(match.group(), 476 | '%Y-%m-%d %H:%M:%S').timestamp() 477 | else: 478 | file_ts = 0 479 | 480 | soupline = bs4.BeautifulSoup(line, 'html.parser') 481 | link = soupline.a 482 | if link: 483 | link_text = link.get_text(strip=True) 484 | # Oscar expects STR.edf, not STR.EDF 485 | if link_text == 'STR.EDF': 486 | link_text = 'STR.edf' 487 | 488 | link_href = link['href'] 489 | 490 | if link_text in self.ignore or link_text.startswith('.'): 491 | continue 492 | parsed_url = urllib.parse.urlparse(link_href) 493 | if parsed_url.path.endswith('download'): 494 | files.append((link_text, parsed_url.query, file_ts)) 495 | elif parsed_url.path.endswith('dir'): 496 | dirs.append((link_text, link_href)) 497 | return files, dirs 498 | 499 | def check_files(self, files, url, dir_path: pathlib.Path): 500 | """ 501 | Determine if files should be downloaded or skipped and downloads the 502 | correct files 503 | 504 | Args: 505 | files (list[Tuple[str,str,float]]): A list containing a tuple for 506 | each file in the current directory 507 | [0] (str): Name of the file 508 | [1] (str): URL component to the file 509 | [2] (float): Modification time of the file in as a POSIX 510 | timestamp 511 | url (str): URL to the current directory 512 | dir_path (pathlib.Path): Local path to curent directory 513 | """ 514 | for filename, file_url, file_ts in files: 515 | local_path = dir_path / filename 516 | absolute_file_url = urllib.parse.urljoin(url, f'download?{file_url}') 517 | self.download_file(absolute_file_url, local_path, file_ts=file_ts) 518 | 519 | def download_file(self, url, file_path: pathlib.Path, file_ts: float): 520 | """ 521 | Grab a single file from the SD card. 522 | 523 | Args: 524 | url (str): url to the file to download 525 | file_path (pathlib.Path): Path of the file 526 | file_ts (float): Modification time of the file in as a POSIX 527 | timestamp 528 | 529 | Raises: 530 | SystemExit: When the download fails 531 | """ 532 | mtime = 0 533 | already_exists = file_path.is_file() 534 | if self.start_time.timestamp() > file_ts: 535 | logger.debug('File at %s is older than specified start time, skipping', 536 | url) 537 | return 538 | if already_exists and self.keep_old: 539 | logger.info('File %s already exists and keep_old is set, skipping',str(file_path)) 540 | return 541 | if already_exists: 542 | mtime = file_path.stat().st_mtime 543 | if self.overwrite or mtime < file_ts: 544 | logger.debug('Downloading %s from %s', str(file_path), url) 545 | response = self.session.get(url, stream=True) 546 | response.raise_for_status() 547 | 548 | total_size = int(response.headers.get('content-length', 0)) 549 | block_size = 1024 550 | 551 | with tqdm.tqdm(total=total_size, unit='B', unit_scale=True, 552 | desc=file_path.name, 553 | disable=not self.show_progress) as progress_bar: 554 | with file_path.open('wb') as fp: 555 | for data in response.iter_content(block_size): 556 | progress_bar.update(len(data)) 557 | fp.write(data) 558 | if already_exists: 559 | if mtime < file_ts: 560 | logger.info('file at %s is newer than %s, overwritten', url, str(file_path)) 561 | else: 562 | logger.info('%s overwritten', str(file_path)) 563 | else: 564 | logger.info('%s written', str(file_path)) 565 | if file_ts: 566 | os.utime(file_path, (file_ts, file_ts)) 567 | return 568 | else: 569 | logger.info('File %s already exists and has not been updated. Skipping because overwrite is off.', 570 | str(file_path)) 571 | return 572 | 573 | 574 | def check_dirs(self, dirs, url, dir_path: pathlib.Path): 575 | """ 576 | Determine if folders should be included or skipped, create new folders 577 | where necessary 578 | 579 | Args: 580 | dirs (list[Tuple[str,str]]): A list of tuples for each directory in 581 | the current directory 582 | [0] (str): Name of the directory 583 | [1] (str): URL component to the directory 584 | url (str): URL to current directory 585 | dir_path (pathlib.Path): Local path to current directory 586 | """ 587 | for dirname, dir_url in dirs: 588 | new_dir_path = dir_path / dirname 589 | new_dir_path.mkdir(exist_ok=True) 590 | absolute_dir_url = urllib.parse.urljoin(url, dir_url) 591 | self.recursive_traversal(absolute_dir_url, new_dir_path) 592 | 593 | def disconnect_from_wifi(self): 594 | """ 595 | Disconnects from the WiFi specified by self.ssid and attempts to 596 | reconnect to the original network if possible 597 | 598 | Raises: 599 | RuntimeError: When an error occurs disconnecting from the Wi-Fi 600 | network or reconnecting to the existing network 601 | """ 602 | if self.platform_system == 'Darwin': 603 | if self.connection_id: 604 | self.print(f'Disconnecting from {self.connection_id}...') 605 | 606 | self.print(f'Removing profile for {self.connection_id}...') 607 | profile_cmd = f'networksetup -removepreferredwirelessnetwork {self.interface_name} "{self.connection_id}"' 608 | try: 609 | subprocess.run(profile_cmd, shell=True, 610 | capture_output=True, text=True, check=True) 611 | except subprocess.CalledProcessError as e: 612 | raise RuntimeError(f'Error removing network profile for {self.ssid}. Return code: {e.returncode}, error: {e.stderr}') from e 613 | try: 614 | # Turn off the Wi-Fi interface 615 | subprocess.run(f'networksetup -setairportpower {self.interface_name} off', 616 | shell=True, check=True) 617 | logger.info('Wi-Fi interface %s turned off', 618 | self.interface_name) 619 | # Turn it back on 620 | subprocess.run(f'networksetup -setairportpower {self.interface_name} on', 621 | shell=True, check=True) 622 | logger.info('Wi-Fi interface %s turned on', 623 | self.interface_name) 624 | except subprocess.CalledProcessError as e: 625 | raise RuntimeError(f'Error toggling Wi-Fi interface power. Return code: {e.returncode}, error: {e.stderr}') from e 626 | 627 | elif self.platform_system == 'Linux' and self.interface_name is not None: 628 | if self.connected: 629 | self.print(f'Disconnecting from {self.ssid}') 630 | disconnect_cmd = f'nmcli connection down {self.connection_id}' 631 | try: 632 | subprocess.run(disconnect_cmd, shell=True, 633 | capture_output=True, text=True, check=True) 634 | logger.info('Successfully disconnected from %s', self.ssid) 635 | except subprocess.CalledProcessError as e: 636 | raise RuntimeError(f'Error disconnecting from {self.ssid}. Return code: {e.returncode}, error: {e.stderr}') from e 637 | if self.connection_id: 638 | self.print(f'Removing profile for {self.connection_id}...') 639 | delete_cmd = f'nmcli connection delete "{self.connection_id}"' 640 | try: 641 | subprocess.run(delete_cmd, shell=True, capture_output=True, 642 | text=True, check=True) 643 | logger.info('Successfully removed network profile for %s', 644 | self.connection_id) 645 | except subprocess.CalledProcessError as e: 646 | raise RuntimeError(f'Error removing network profile for {self.ssid}. Return code: {e.returncode}, error: {e.stderr}') from e 647 | 648 | elif self.platform_system == 'Windows': 649 | if self.connection_id: 650 | self.print(f'Removing profile for {self.connection_id}...') 651 | profile_cmd = f'netsh wlan delete profile "{self.connection_id}"' 652 | try: 653 | subprocess.run(profile_cmd, shell=True, 654 | capture_output=True, text=True, check=True) 655 | logger.info('Successfully removed network profile for %s', 656 | self.connection_id) 657 | except subprocess.CalledProcessError as e: 658 | raise RuntimeError(f'Error removing network profile for {self.ssid}. Return code: {e.returncode}, error: {e.stderr}') from e 659 | if self.existing_connection_id: 660 | self.print(f'Reconnecting to {self.existing_connection_id}...') 661 | connect_cmd = f'netsh wlan connect name="{self.existing_connection_id}"' 662 | try: 663 | subprocess.run(connect_cmd, shell=True, 664 | capture_output=True, text=True, check=True) 665 | logger.info('Successfully reconnected to original network profile: %s', 666 | self.existing_connection_id) 667 | except subprocess.CalledProcessError as e: 668 | raise RuntimeError(f'Error reconnecting to original network profile: {self.existing_connection_id}. Return code: {e.returncode}, error: {e.stderr}') from e 669 | 670 | 671 | def main(): 672 | """ 673 | Entry point when used as a CLI tool 674 | """ 675 | CONNECTION_DELAY = 7 676 | 677 | CONFIG_FILES = [ 678 | pathlib.Path(f'{APP_NAME}.ini'), # In the same directory as the script 679 | pathlib.Path(f'~/.config/{APP_NAME}.ini').expanduser(), 680 | pathlib.Path(f'~/.config/{APP_NAME}/{APP_NAME}.ini').expanduser(), 681 | pathlib.Path(f'~/.config/{APP_NAME}/config.ini').expanduser(), 682 | ] 683 | 684 | # Iterate through possible paths and use the first one that exists 685 | config_path = None 686 | for config_f in CONFIG_FILES: 687 | if config_f.is_file(): 688 | config_path = config_f 689 | break 690 | 691 | config = configparser.ConfigParser() 692 | # If config file is found, read its contents 693 | if config_path: 694 | # Create a configparser object and read the config file 695 | config.read(config_path) 696 | 697 | # Set defaults using the config file or the hardcoded defaults 698 | path = config.get(f'{APP_NAME}', 'path', 699 | fallback=str(pathlib.Path('~/Documents/CPAP_Data/SD_card').expanduser())) 700 | url = config.get(f'{APP_NAME}', 'url', 701 | fallback='http://192.168.4.1/dir?dir=A:') 702 | start_from = config.get(f'{APP_NAME}', 'start_from', fallback=None) 703 | day_count = config.getint(f'{APP_NAME}', 'day_count', fallback=None) 704 | show_progress = config.getboolean(f'{APP_NAME}', 'show_progress', 705 | fallback=False) 706 | verbose = config.getboolean(f'{APP_NAME}', 'verbose', fallback=False) 707 | overwrite = config.getboolean(f'{APP_NAME}', 'overwrite', fallback=False) 708 | keep_old = config.getboolean(f'{APP_NAME}', 'keep_old', fallback=False) 709 | ignore = config.get(f'{APP_NAME}', 'ignore', 710 | fallback='JOURNAL.JNL,ezshare.cfg,System Volume Information') 711 | ssid = config.get(f'{APP_NAME}', 'ssid', fallback=None) 712 | psk = config.get(f'{APP_NAME}', 'psk', fallback=None) 713 | retries = config.getint(f'{APP_NAME}', 'retries', fallback=5) 714 | 715 | # Parse command line arguments 716 | description = textwrap.dedent(f"""\ 717 | {APP_NAME} wirelessly syncs Resmed CPAP/BiPAP treatment data logs stored on a EZShare WiFi SD card wirelessly to your local device 718 | 719 | A configuration file can be used to set defaults 720 | See documentation for configuration file options, default locations, and precedence 721 | Command line arguments will override the configuration file 722 | """) 723 | epilog = textwrap.dedent(f"""\ 724 | NOTE: 725 | Example: 726 | {APP_NAME} --ssid ezShare --psk 88888888 --show_progress 727 | """) 728 | parser = argparse.ArgumentParser(prog=APP_NAME, description=description, 729 | epilog=epilog, 730 | formatter_class=argparse.RawDescriptionHelpFormatter) 731 | parser.add_argument('--path', type=str, 732 | help=f'set destination path, defaults to {path}') 733 | parser.add_argument('--url', type=str, 734 | help=f'set source URL, Defaults to {url}') 735 | parser.add_argument('--start_from', type=str, 736 | help=f'start from date in YYYYMMDD format, deaults to {start_from}; this will override day_count if set') 737 | parser.add_argument('--day_count', '-n', type=int, 738 | help=f'number of days to sync, defaults to {day_count}; if both start_from and day_count are unset all files will be synced') 739 | parser.add_argument('--show_progress', action='store_true', 740 | help=f'show progress, defaults to {show_progress}') 741 | parser.add_argument('--verbose', '-v', action='store_true', 742 | help=f'verbose output, defaults to {verbose}') 743 | parser.add_argument('--debug', '-vvv', action='store_true', 744 | help=argparse.SUPPRESS) 745 | parser.add_argument('--overwrite', action='store_true', 746 | help=f'force overwriting existing files, defaults to {overwrite}') 747 | parser.add_argument('--keep_old', action='store_true', 748 | help=f'do not overwrite even if newer version is available, defaults to {overwrite}') 749 | parser.add_argument('--ignore', type=str, 750 | help=f'case insensitive comma separated list (no spaces) of files to ignore, defaults to {ignore}') 751 | parser.add_argument('--ssid', type=str, 752 | help=f'set network SSID; WiFi connection will be attempted if set, defaults to {ssid}') 753 | parser.add_argument('--psk', type=str, 754 | help='set network pass phrase, defaults to None') 755 | parser.add_argument('--retries', type=int, 756 | help=f'set number of retries for failed downloads, defaults to {retries}') 757 | parser.add_argument('--version', action='version', 758 | version=f'{APP_NAME} {VERSION}') 759 | args = parser.parse_args() 760 | 761 | if args.path: 762 | path = args.path 763 | if args.url: 764 | url = args.url 765 | if args.start_from: 766 | start_from = args.start_from 767 | if args.day_count: 768 | day_count = args.day_count 769 | if args.show_progress: 770 | show_progress = True 771 | if args.verbose: 772 | verbose = True 773 | if args.overwrite: 774 | overwrite = True 775 | if args.keep_old: 776 | keep_old = True 777 | if args.ignore: 778 | ignore = args.ignore 779 | if args.ssid: 780 | ssid = args.ssid 781 | if args.psk: 782 | psk = args.psk 783 | if args.retries: 784 | retries = args.retries 785 | 786 | ignore_list = ignore.split(',') 787 | 788 | if start_from: 789 | try: 790 | start_ts = datetime.datetime.strptime(start_from, '%Y%m%d') 791 | except ValueError as e: 792 | raise ValueError(f'Invalid date format provided in \'start_from\'. Please use YYYYMMDD. Error: {e}') from e 793 | elif day_count: 794 | start_ts = datetime.datetime.now() - datetime.timedelta(days=day_count) 795 | else: 796 | start_ts = datetime.datetime.fromtimestamp(0, datetime.timezone.utc) 797 | 798 | ezshare = EZShare(path, url, start_ts, show_progress, verbose, overwrite, 799 | keep_old, ssid, psk, ignore_list, retries, 800 | CONNECTION_DELAY, args.debug) 801 | 802 | try: 803 | ezshare.run() 804 | except BaseException as e: 805 | raise e 806 | finally: 807 | ezshare.disconnect_from_wifi() 808 | ezshare.print('Complete') 809 | 810 | 811 | if __name__ == '__main__': 812 | main() 813 | --------------------------------------------------------------------------------