Save the reporting program as softwell_report.py, package it with pyinstaller --onefile --windowed --name Softwell_Report softwell_report.py, deploy the generated EXE to a fixed local folder on the WinCC station, and configure the Softwell_Report button’s VBScript click action to run that absolute EXE path. With no command-line arguments, the EXE opens the date/time filter popup and generates the Excel report.
- Python is not required on the runtime PC after a successful standalone build.
- The SQL Server ODBC driver, Windows database permission and output-folder permission are still required.
- Always prove the EXE independently before connecting it to the WinCC button.
How This Python-to-EXE WinCC Solution Fits Together
This tutorial answers the complete search path: how to convert Python to EXE with PyInstaller, launch the standalone executable from a Siemens WinCC Explorer button, collect an operator-selected date/time range, query SQL Server, and export the filtered result to Excel.
Related Search Topics
convert Python script to EXE on Windows · launch EXE from WinCC button · WinCC SQL Server Excel report · PyInstaller tkinter pandas openpyxl
1. WinCC One-Button Excel Reporting Workflow
WinCC Button → Python EXE → SQL → Excel
WinCC launches an external packaged Python reporting application. The EXE collects filters, queries SQL Server and generates the Excel workbook outside the WinCC project.
WinCC Button
Operator clicks the configured report button in Graphics Runtime.
Launch EXE
Windows starts the packaged Python application.
Filter UI
Tkinter collects start date, end date and furnace selection.
Query SQL
pyodbc reads the matching SQF_DB event rows.
Build Report
pandas organizes data and openpyxl formats the workbook.
Confirm Output
Show the saved file path and optionally open the report.
Code-rendered diagram: the runtime station executes the packaged EXE; Python does not need to be exposed as editable source code to the WinCC operator.
The EXE is an external reporting application. WinCC launches it but does not need to host the Python GUI or Excel-generation libraries inside the WinCC project.
Lab Manual Index: 9 Guided Exercises
Every section of this tutorial carries its own lab manual — objective, procedure, a line-by-line explanation, copy-paste practice code, the exact output to expect, a checkpoint and a troubleshooting table. Work through them in order; the EXE and WinCC labs assume the Python labs before them already pass. Total bench time is about 80 minutes.
2. Software and Deployment Requirements
- Build PC: compatible Python,
pyodbc,pandas,openpyxlandpyinstaller. - Runtime PC: supported Windows version and Microsoft ODBC Driver 17 for SQL Server.
- Network access from the WinCC station to
SOFTWELL\WINCC. - Windows identity running the EXE must have
SELECTaccess toSQF_DB.dbo.tblEvent. - Write permission to the selected report folder.
- WinCC Graphics Designer permission to configure and execute the button action.
python -m pip install --upgrade pip
python -m pip install pyodbc pandas openpyxl pyinstaller3. How the Python Application Works
The program supports two modes:
- Popup mode: double-click the EXE or launch it from WinCC with no arguments.
run_popup()opens the operator form. - Command-line mode: pass start, end, optional furnace and output folder. This supports unattended or parameter-driven launches.
def main():
if len(sys.argv) == 1:
run_popup()
else:
run_command_line()
if __name__ == "__main__":
main()The attached source duplicated this entry-point block. The corrected version in this blog contains it only once, preventing the GUI or command-line report from running twice.
Lab Manual 3 — Understand the Dual-Mode Application
6 minObjective
Prove that the same program opens a filter popup when double-clicked and runs headless when WinCC passes arguments, so one EXE serves both the operator and the SCADA button.
Procedure
- Run
python softwell_report.pywith no arguments and confirm the popup opens. - Close it, then run the same file with a start and end date on the command line.
- Confirm the second run produces a workbook with no window appearing.
- Read
sys.argvin both cases so the switch is not a mystery.
What each line does
| Code | Meaning in this lab |
|---|---|
| len(sys.argv) == 1 | sys.argv[0] is always the script or EXE path. A length of one means no arguments were supplied, so a human started it. |
| run_popup() | Opens the tkinter filter window for an operator. |
| run_command_line() | Parses arguments with argparse, so WinCC can pass a date range and furnace number. |
| if __name__ == "__main__": | Lets another script import generate_report() without launching a window. |
| getattr(sys, "frozen", False) | True only inside a PyInstaller EXE. It is how application_folder() finds the right folder in both modes. |
Copy-paste practice code
import sys
from pathlib import Path
def application_folder():
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
print("Arguments :", sys.argv)
print("Argument count :", len(sys.argv))
print("Running as EXE :", getattr(sys, "frozen", False))
print("App folder :", application_folder())
print("Mode selected :", "popup" if len(sys.argv) == 1 else "command line")Run this file twice — once by double-clicking and once as python 01_mode_check.py 2026-07-20 2026-07-27. The same file must report two different modes.
Expected result
Arguments : ['01_mode_check.py']
Argument count : 1
Running as EXE : False
App folder : C:\Softwell_Report
Mode selected : popupCheckpoint
If it fails
| Symptom | What to check |
|---|---|
| The popup opens even from WinCC | WinCC is launching the EXE with no arguments. Add the date range to the shell.Run command line. |
| NameError: __file__ is not defined | You are inside an interactive shell or a frozen EXE. The frozen branch exists for exactly this reason. |
| Reports save to a random folder | The working directory differs from the EXE folder. Always build paths from application_folder(), never from a relative path. |
4. Validate Date, Time and Furnace Filters
def parse_datetime(value, end_of_day=False):
"""Accept YYYY-MM-DD or YYYY-MM-DD HH:MM[:SS]."""
try:
parsed = datetime.fromisoformat(value.strip())
except ValueError as error:
raise ValueError(
"Use YYYY-MM-DD HH:MM:SS, for example 2026-07-27 14:30:00"
) from error
# A date-only entry has 10 characters, so supply the missing time
if len(value.strip()) == 10:
parsed = datetime.combine(
parsed.date(),
time(23, 59, 59) if end_of_day else time.min,
)
return parsedThe form accepts YYYY-MM-DD or YYYY-MM-DD HH:MM[:SS]. A date-only start becomes midnight; a date-only end becomes 23:59:59. The program rejects an end before the start. “All Furnaces” becomes None; values 1–3 become integers.
For maximum precision, consider making the end boundary exclusive—such as the next day at midnight—especially when SQL timestamps can contain fractions beyond whole seconds.
Lab Manual 4 — Validate Date, Time and Furnace Filters
8 minObjective
Turn operator text into real datetime objects, supply a sensible time when only a date is typed, and fail with a helpful message rather than a traceback.
Procedure
- Run the function against a date-only value for both start and end.
- Run it against a full timestamp.
- Run it against deliberate rubbish and read the error message.
- Confirm that an end date earlier than the start is rejected.
What each line does
| Code | Meaning in this lab |
|---|---|
| datetime.fromisoformat(...) | Parses ISO-8601 text. It accepts both 2026-07-27 and 2026-07-27 14:30:00. |
| len(value.strip()) == 10 | A date-only entry is exactly ten characters. That is how the code detects a missing time. |
| time(23, 59, 59) if end_of_day else time.min | A date-only start becomes 00:00:00 and a date-only end becomes 23:59:59, so the whole day is included. |
| raise ValueError(...) from error | Replaces the cryptic parser message with an example format, while from error keeps the original chain for the log. |
| sqf_no = None if furnace_value in {"", "All Furnaces"} | An empty box or the default text means no furnace filter is applied. |
Copy-paste practice code
from datetime import datetime, time
tests = [
("2026-07-27", False),
("2026-07-27", True),
("2026-07-27 14:30:00", False),
("27/07/2026", False),
("", False),
]
for value, end_of_day in tests:
label = f"{value!r:<24} end_of_day={end_of_day}"
try:
print(f"{label} -> {parse_datetime(value, end_of_day)}")
except ValueError as error:
print(f"{label} -> REJECTED: {error}")
for furnace_text in ("All Furnaces", "", "2", "abc"):
try:
value = furnace_text.strip()
sqf_no = None if value in {"", "All Furnaces"} else int(value)
print(f"furnace {furnace_text!r:<15} -> {sqf_no}")
except ValueError:
print(f"furnace {furnace_text!r:<15} -> REJECTED: not a number")Paste this below the parse_datetime() function. The two rejected cases are the point of the drill — an operator will type both of them eventually.
Expected result
'2026-07-27' end_of_day=False -> 2026-07-27 00:00:00
'2026-07-27' end_of_day=True -> 2026-07-27 23:59:59
'2026-07-27 14:30:00' end_of_day=False -> 2026-07-27 14:30:00
'27/07/2026' end_of_day=False -> REJECTED: Use YYYY-MM-DD HH:MM:SS, for example 2026-07-27 14:30:00
'' end_of_day=False -> REJECTED: Use YYYY-MM-DD HH:MM:SS, for example 2026-07-27 14:30:00
furnace 'All Furnaces' -> None
furnace '' -> None
furnace '2' -> 2
furnace 'abc' -> REJECTED: not a numberCheckpoint
If it fails
| Symptom | What to check |
|---|---|
| A whole day of data is missing from the report | The end date was treated as midnight. end_of_day=True must be passed for the end value. |
| Operators type dd/mm/yyyy and it fails | fromisoformat only accepts ISO order. Either train the format or add a second parse attempt with strptime. |
| A furnace typo crashes the popup | int() raises before the handler. Validate the combo box value before converting, or catch ValueError around it. |
| End before start is accepted | The comparison in generate_report() was removed. It must run before the query. |
5. Query SQL Server with Bound Parameters
query = """
SELECT
[DT], [TM], [SQF_No], [ChargeNo],
[Event_From], [Event_To],
[Temp_Set], [Temp_Act],
[Cp_Set], [Cp_Act],
[Oil_Set], [Oil_Act],
[Jacket_Set], [Jacket_Act],
[Fan_Status]
FROM [dbo].[tblEvent]
WHERE [DT] >= ? AND [DT] < ?
"""
params = [start_datetime.date(), end_datetime.date() + timedelta(days=1)]
if sqf_no is not None:
query += " AND [SQF_No] = ?"
params.append(sqf_no)
query += " ORDER BY [DT] DESC, [TM] DESC;"The initial SQL range safely narrows the candidate dates. The cursor results become a pandas DataFrame. Because this schema stores date and time separately, the program converts both columns and creates Event_Timestamp, then applies the exact inclusive range in pandas.
datetime2 event timestamp; SQL Server can then apply the exact range efficiently.Lab Manual 5 — Query SQL Server with Bound Parameters
9 minObjective
Fetch only the days that can possibly match using bound parameters, then narrow to the exact minute in pandas, and understand why the work is split across the two.
Procedure
- Build the query with no furnace filter and print it.
- Build it again with a furnace number and compare the two statements and parameter lists.
- Run it and check the row count before the time filter is applied.
- Apply the
Event_Timestampfilter and check the count again.
What each line does
| Code | Meaning in this lab |
|---|---|
| WHERE [DT] >= ? AND [DT] < ? | A half-open date range. Using < end + 1 day instead of <= includes the whole final day whatever the time type. |
| end_datetime.date() + timedelta(days=1) | Builds that exclusive upper bound. Both values go in as parameters, never as formatted text. |
| query += " AND [SQF_No] = ?" | The filter is appended only when a furnace was chosen, and its value is appended to params in the same order. |
| cursor.execute(query, params) | Positional binding. The order of params must match the order the markers appear in the finished statement. |
| pd.to_timedelta(dataframe["TM"]) | Converts the separate TIME column so it can be added to the normalised date. |
| .between(start_datetime, end_datetime) | The exact time filter. SQL narrowed to whole days; pandas narrows to the minute. |
Copy-paste practice code
from datetime import timedelta
def build_query(start_datetime, end_datetime, sqf_no=None):
query = "SELECT ... FROM [dbo].[tblEvent] WHERE [DT] >= ? AND [DT] < ?"
params = [start_datetime.date(), end_datetime.date() + timedelta(days=1)]
if sqf_no is not None:
query += " AND [SQF_No] = ?"
params.append(sqf_no)
query += " ORDER BY [DT] DESC, [TM] DESC;"
return query, params
start = parse_datetime("2026-07-20")
end = parse_datetime("2026-07-27", end_of_day=True)
for sqf_no in (None, 2):
query, params = build_query(start, end, sqf_no)
print("furnace:", sqf_no)
print(" markers :", query.count("?"))
print(" parameters:", len(params), params)
print(" statement :", query[-60:])
print()The marker count and the parameter count must always match. This drill is the fastest way to catch an appended filter whose value was forgotten.
Expected result
furnace: None
markers : 2
parameters: 2 [datetime.date(2026, 7, 20), datetime.date(2026, 7, 28)]
statement : ORDER BY [DT] DESC, [TM] DESC;
furnace: 2
markers : 3
parameters: 3 [datetime.date(2026, 7, 20), datetime.date(2026, 7, 28), 2]
statement : [SQF_No] = ? ORDER BY [DT] DESC, [TM] DESC;Checkpoint
If it fails
| Symptom | What to check |
|---|---|
| COUNT field incorrect or syntax error | A filter was appended without appending its value, so markers and parameters no longer match. |
| The last day is missing from the report | The upper bound used <= against a date, which excludes everything after midnight on the final day. |
| ORDER BY appears in the middle of the query | The furnace filter was appended after the ORDER BY. Append filters first, then the sort. |
| The time filter removes every row | TM did not convert to a timedelta, so Event_Timestamp is NaT. Print dataframe['TM'].head() to see the real format. |
6. Generate the Formatted Excel Report
The exporter creates the folder, uses a timestamped filename, writes data below a report header, and adds title, selected period, furnace, record count, header styling, filters, frozen panes, widths and date formatting.
output_file = output_folder / f"SQF_Filtered_Report_{furnace_text}_{stamp}.xlsx"
with pd.ExcelWriter(output_file, engine="openpyxl") as writer:
dataframe.to_excel(
writer,
sheet_name="Filtered Report",
index=False,
startrow=4,
)
worksheet = writer.sheets["Filtered Report"]
# Rows 1 to 3 hold the report title and filter summary
worksheet["A1"] = "SQF FURNACE EVENT REPORT"
# Data headers land on row 5, so freeze below them
worksheet.freeze_panes = "A6"
worksheet.auto_filter.ref = worksheet.dimensionsUsing numeric column positions with get_column_letter() avoids failures caused by merged title cells, which do not expose the same column_letter property as normal cells.
Lab Manual 6 — Generate the Formatted Excel Report
10 minObjective
Produce a workbook that looks like a delivered report: merged title, filter summary, formatted header row, frozen panes and readable column widths.
Procedure
- Write the workbook with
startrow=4and open it in Excel. - Confirm the title is merged across the data width and the summary lines read correctly.
- Confirm the header sits on row 5 and the freeze line is under it.
- Check the timestamp column formatting from row 6 down.
What each line does
| Code | Meaning in this lab |
|---|---|
| startrow=4 | Leaves rows 1 to 4 free, so the data header lands on row 5. Change this and every row number below must change too. |
| worksheet.merge_cells(...) | Merges the title across the data width, using max(1, len(dataframe.columns)) so an empty result cannot produce a zero-column merge. |
| PatternFill("solid", fgColor="1F4E78") | The navy title band. solid must be supplied or no colour appears. |
| header_row = 5 | The data header. It is a separate variable precisely because startrow decides it. |
| worksheet.freeze_panes = "A6" | Freezes everything above row 6, so the title and header stay visible while scrolling. |
| get_column_letter(column_number) | Row 1 holds merged cells, which have no column_letter attribute. The numeric position is used instead — this is the fix for a common crash. |
| iter_rows(min_row=6) | Applies the datetime number format to data rows only, skipping the title block. |
Copy-paste practice code
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
workbook = load_workbook(output_file)
worksheet = workbook["Filtered Report"]
print("A1 title :", worksheet["A1"].value)
print("A1 fill :", worksheet["A1"].fill.fgColor.rgb)
print("A2 period :", worksheet["A2"].value)
print("A3 summary :", worksheet["A3"].value)
print("merged :", list(worksheet.merged_cells.ranges))
print("header row 5:", [cell.value for cell in worksheet[5]][:4])
print("freeze panes:", worksheet.freeze_panes)
print("auto filter :", worksheet.auto_filter.ref)
print("A6 format :", worksheet["A6"].number_format)
print("data rows :", worksheet.max_row - 5)
workbook.close()Reading the saved file back is the only honest check. A script that prints “report generated” without reopening the workbook has proved nothing.
Expected result
A1 title : SQF FURNACE EVENT REPORT
A1 fill : 001F4E78
A2 period : Period: 2026-07-20 00:00:00 to 2026-07-27 23:59:59
A3 summary : Furnace: All | Records: 50
merged : [<MergedCellRange A1:P1>]
header row 5: ['Event_Timestamp', 'DT', 'TM', 'SQF_No']
freeze panes: A6
auto filter : A1:P55
A6 format : DD-MM-YYYY HH:MM:SS
data rows : 50Checkpoint
A6, and the timestamp column carries the date format from row 6 down.If it fails
| Symptom | What to check |
|---|---|
| AttributeError: column_letter on MergedCell | The width loop used column_cells[0].column_letter. Merged cells have no such attribute — use get_column_letter(column_number) as the corrected code does. |
| The filter dropdowns sit on the title row | auto_filter.ref = worksheet.dimensions starts at A1, which includes the title block. Set it to the header range instead: f"A5:{get_column_letter(worksheet.max_column)}{worksheet.max_row}". |
| The header is on the wrong row | startrow and header_row disagree. header_row must be startrow + 1. |
| Title colour does not appear | PatternFill was created without the solid fill type. |
| Timestamps show as numbers | The column is not a real datetime. Check that Event_Timestamp was built before export. |
7. Create a Python EXE with PyInstaller
- Save the corrected program as
softwell_report.py. - Open Command Prompt in that folder.
- Create and activate an approved virtual environment if required by your deployment process.
- Install the dependencies.
- Run the PyInstaller command.
python -m PyInstaller --clean --noconfirm --onefile --windowed --name Softwell_Report softwell_report.pyOn Windows Command Prompt, enter the command on one line. The generated application is normally located at:
dist\Softwell_Report.exe| Option | Purpose |
|---|---|
--onefile | Packages the application into one deployable EXE |
--windowed | Prevents a console window behind the Tkinter popup |
--name Softwell_Report | Creates the required operator-facing filename |
--clean | Clears cached build artifacts |
--noconfirm | Replaces previous build output without prompting |
If a clean test reports a missing module or data file, inspect the PyInstaller warning file and add only the required hidden import or collection option. Avoid copying an untested build directly to a production HMI.
Lab Manual 7 — Build the EXE with PyInstaller
10 minObjective
Compile the script into a single EXE that runs on a WinCC station with no Python installed, and understand what each build flag changes.
Procedure
- Activate the virtual environment that already has pyodbc, pandas and openpyxl.
- Run the PyInstaller command from the folder containing the script.
- Wait for the build to finish and find the EXE in the
distfolder. - Note the build time and the EXE size so a later rebuild can be compared.
What each line does
| Code | Meaning in this lab |
|---|---|
| python -m PyInstaller | Runs PyInstaller from the active environment, so it bundles the packages that environment actually has. |
| --clean | Deletes the previous build cache. Skip it and a stale module can silently survive into the new EXE. |
| --noconfirm | Overwrites the previous dist folder without prompting, which matters for a scripted rebuild. |
| --onefile | Produces a single EXE instead of a folder. It is easier to deploy but slower to start, because it unpacks to a temp folder each run. |
| --windowed | Suppresses the console window. Correct for the operator popup — but it also hides command-line output, so test headless mode before adding this flag. |
| --name Softwell_Report | Sets the EXE name. Without it the EXE takes the script filename. |
Copy-paste practice code
cd C:\SoftwellLabs\wincc-report
.venv\Scripts\activate
python -m PyInstaller --clean --noconfirm --onefile --windowed --name Softwell_Report softwell_report.py
dir dist
python -c "import os;p=r'dist\Softwell_Report.exe';print(p, round(os.path.getsize(p)/1048576,1), 'MB')"Record the size. A sudden jump on a later rebuild usually means an unintended package was pulled into the environment.
Expected result
Building EXE from EXE-00.toc completed successfully.
Directory of C:\SoftwellLabs\wincc-report\dist
19-09-2026 18:06 42,318,912 Softwell_Report.exe
dist\Softwell_Report.exe 40.4 MBCheckpoint
dist, and its size is in the tens of megabytes — pandas alone accounts for most of that.If it fails
| Symptom | What to check |
|---|---|
| ModuleNotFoundError when the EXE runs | PyInstaller bundled a different environment. Activate the correct venv before building, and rebuild with --clean. |
| Antivirus quarantines the EXE | Common with --onefile. Add a folder exclusion on the WinCC station, or ship the folder build instead. |
| The EXE takes 10 seconds to start | Expected for --onefile, which unpacks to a temp folder. Drop --onefile for a faster folder-based build. |
| A console window flashes | --windowed was omitted. Add it, but only after the command-line mode is proven. |
| tkinter missing in the EXE | The Python installation lacks tcl/tk. Reinstall Python with the tcl/tk option enabled and rebuild. |
8. Test the EXE Before WinCC Integration
- Run
dist\Softwell_Report.exedirectly. - Confirm the popup opens once.
- Generate an “All Furnaces” report for a short known period.
- Generate a single-furnace report.
- Verify record count, first/last timestamp, workbook formatting and output folder.
- Test invalid date text, end-before-start, no-data range, SQL outage and unwritable folder.
- Test from the same Windows account/context used by WinCC Runtime.
A successful interactive desktop test does not automatically prove the WinCC Runtime launch context has the same database, filesystem or desktop permissions.
Lab Manual 8 — Test the EXE Before WinCC Integration
8 minObjective
Confirm both modes of the compiled EXE on the target machine before any SCADA object is touched, so later faults can be isolated to the button rather than the program.
Procedure
- Copy the EXE to its deployment folder on the WinCC station.
- Double-click it and generate a report from the popup.
- Run it from a command prompt with a date range and confirm a file appears with no window.
- Check that the report folder is created beside the EXE, not in a temp directory.
What each line does
| Code | Meaning in this lab |
|---|---|
| dist\Softwell_Report.exe | The build output. Copy this one file to the station; nothing else from dist or build is needed for a onefile build. |
| Popup mode | Started with no arguments. Proves tkinter, the SQL connection and Excel export all survived compilation. |
| Command-line mode | Started with arguments. Proves the path WinCC will actually use. |
| application_folder() | Inside the EXE this resolves to the EXE's own folder, not the PyInstaller temp folder — that is why reports land next to the EXE. |
Copy-paste practice code
cd C:\Softwell_Report
REM 1. Command-line mode, all furnaces
Softwell_Report.exe 2026-07-20 2026-07-27
REM 2. Command-line mode, single furnace
Softwell_Report.exe 2026-07-20 2026-07-27 2
REM 3. Explicit output folder
Softwell_Report.exe 2026-07-20 2026-07-27 --output C:\Softwell_Report\SQF_Reports
REM 4. Confirm the workbooks landed
dir SQF_Reports\*.xlsxBuild the EXE without --windowed for this test so the console output is visible, then rebuild with it once the four commands pass.
Expected result
Exported 1284 records to:
C:\Softwell_Report\SQF_Reports\SQF_Filtered_Report_All_19-09-2026_18-11-02.xlsx
Directory of C:\Softwell_Report\SQF_Reports
19-09-2026 18:11 34,816 SQF_Filtered_Report_All_19-09-2026_18-11-02.xlsx
19-09-2026 18:11 21,504 SQF_Filtered_Report_SQF-2_19-09-2026_18-11-04.xlsxCheckpoint
If it fails
| Symptom | What to check |
|---|---|
| Nothing happens when the EXE is double-clicked | It was built with --windowed and crashed before the window appeared. Rebuild without that flag to see the error. |
| Login failed for user | The EXE runs under a different Windows account on the station. Grant that account SELECT on the database. |
| Reports appear in a Temp folder | A relative path was used instead of application_folder(). In a onefile EXE the working directory is a temp extraction folder. |
| The EXE works for you but not for the operator | File or folder permissions. Test while logged in as the operator account, not as an administrator. |
9. Launch the Python EXE from a WinCC Explorer Button
In WinCC Graphics Designer, add a button labeled Softwell_Report. On its mouse-click event, configure a VBScript action similar to the following and use the actual deployed path:
Sub OnClick(ByVal Item)
Dim shell
Set shell = CreateObject("WScript.Shell")
shell.Run """C:\Softwell_Report\Softwell_Report.exe""", 1, False
Set shell = Nothing
End SubThe quoted path supports spaces. Window style 1 requests a normal visible window. False tells WinCC not to block while the EXE is running. The exact event wrapper generated by your WinCC version should be preserved; place the WScript.Shell statements inside that generated click procedure.
Lab Manual 9 — Launch the EXE from a WinCC Button
9 minObjective
Attach the EXE to a WinCC Explorer button so an operator generates a report with one click, without freezing WinCC Runtime while the report is produced.
Procedure
- Open the WinCC graphics designer and add a button to the screen.
- Attach a VBScript action to its mouse-click event.
- Paste the script and correct the path to the deployment folder.
- Activate Runtime and click the button.
What each line does
| Code | Meaning in this lab |
|---|---|
| Sub OnClick(ByVal Item) | The standard WinCC VBS click handler. Item is the object that was clicked. |
| CreateObject("WScript.Shell") | Windows Script Host shell object, which is what actually launches the external program. |
| """C:\Softwell_Report\...""" | Three double quotes produce one literal quote in VBScript. The quotes around the path are essential once the path contains a space. |
| shell.Run path, 1, False | The 1 shows the window normally. The False means do not wait — WinCC keeps running while the report is generated. |
| Set shell = Nothing | Releases the COM object. Leaving it set leaks a handle on every click. |
Copy-paste practice code
Sub OnClick(ByVal Item)
Dim shell, exePath, startDate, endDate, command
exePath = "C:\Softwell_Report\Softwell_Report.exe"
' Last 7 days up to today
startDate = FormatDateTime(Date - 7, 2)
endDate = FormatDateTime(Date, 2)
command = """" & exePath & """ " & startDate & " " & endDate
Set shell = CreateObject("WScript.Shell")
shell.Run command, 1, False
Set shell = Nothing
End SubThe simple version in Section 9 opens the popup. This version passes a date range, so the report is produced with no operator input at all. Use whichever the plant asks for.
Expected result
Runtime click -> Softwell_Report.exe launched
Report window appears, WinCC Runtime stays responsive
Workbook created in C:\Softwell_Report\SQF_ReportsCheckpoint
If it fails
| Symptom | What to check |
|---|---|
| Nothing happens on click | The path is wrong or unquoted. Test the exact command in a Run dialog first. |
| WinCC Runtime freezes until the report finishes | The third argument is True. Set it to False so the call does not wait. |
| Windows Script Host error 800A0046 | The Runtime account cannot read the EXE folder. Fix the folder permissions. |
| Two reports appear from one click | The action is attached to both press and release events. Attach it to one only. |
| It works in the designer but not in Runtime | Runtime executes under a different account. Test with that account logged in. |
10. Deploy on the WinCC Runtime Station
A practical local deployment layout is:
C:\Softwell_Report\
├── Softwell_Report.exe
└── SQF_Reports\- Copy the signed/approved EXE to a stable local folder.
- Install the required SQL Server ODBC driver separately.
- Allow the EXE through endpoint-security application controls.
- Grant the WinCC operator/runtime identity read access to SQL and write access to the report folder.
- Use a fixed local path in the WinCC action; avoid user-profile and mapped-drive dependencies.
- Record application version, checksum, build environment and rollback copy.
- Commission the complete workflow while WinCC Runtime is active.
PyInstaller bundles Python code and libraries, but it does not provision SQL Server, install the Microsoft ODBC driver, grant database rights, install Excel, or configure WinCC permissions. Excel itself is optional for generating the XLSX; it is needed only if operators want to open the report in Microsoft Excel.
Lab Manual 10 — Deploy on the WinCC Runtime Station
8 minObjective
Place the EXE and its report folder where the Runtime account has the rights it needs, and confirm the deployment survives a station restart.
Procedure
- Create the deployment folder on the station's local drive.
- Copy in the EXE and create the report subfolder.
- Grant the Runtime account modify rights on the report folder only.
- Restart the station and click the button again.
What each line does
| Code | Meaning in this lab |
|---|---|
| C:\Softwell_Report\ | A local folder. Avoid a network share for the first deployment — a dropped share turns a report failure into a station problem. |
| Softwell_Report.exe | The single compiled file. Nothing else from the build output is needed. |
| SQF_Reports\ | Created automatically by mkdir(parents=True, exist_ok=True), but pre-creating it lets you set permissions in advance. |
| Local drive, not Program Files | Windows protects Program Files, so a normal account cannot write reports there. |
| Least privilege | The Runtime account needs read and execute on the EXE, and modify on the report folder. Nothing more. |
Copy-paste practice code
cd C:\Softwell_Report
REM What is deployed
dir /b
REM Effective permissions on the report folder
icacls SQF_Reports
REM Prove the Runtime account can write there
echo test > SQF_Reports\_writetest.txt
del SQF_Reports\_writetest.txt
echo Write test passed.
REM How many reports exist and how much space they use
dir SQF_Reports\*.xlsx | find "File(s)"Run this while logged in as the WinCC Runtime account, not as an administrator. An administrator can always write, which proves nothing about the operator.
Expected result
Softwell_Report.exe
SQF_Reports
SQF_Reports NT AUTHORITY\SYSTEM:(OI)(CI)(F)
BUILTIN\Administrators:(OI)(CI)(F)
SOFTWELL\WinCCRuntime:(OI)(CI)(M)
Write test passed.
14 File(s) 487,424 bytesCheckpoint
If it fails
| Symptom | What to check |
|---|---|
| Access denied creating the report | The Runtime account lacks modify rights on the report folder. Grant them with icacls or the folder properties dialog. |
| Reports stop appearing after a reboot | The deployment sits on a mapped drive that is not reconnected at logon. Use a local path or a UNC path. |
| The disk fills up over months | Nothing deletes old reports. Add a scheduled cleanup that removes workbooks older than an agreed retention period. |
| The EXE is blocked after copying | Windows marked the file as downloaded. Right-click, Properties, Unblock — or copy it with a method that does not add the zone marker. |
11. Complete Corrected Python Code
r"""
================================================================================
CODE 05: WINCC / WINDOWS FILTER POPUP AND EXCEL REPORT GENERATOR
================================================================================
THEORY
------
An operator selects a start date/time, end date/time, and optional furnace.
Python uses parameterized SQL to read the matching date range, applies the exact
time filter, and exports the result to a professionally formatted Excel workbook.
STEPS
-----
1. Run the Python file or double-click the compiled EXE.
2. Enter start and end date/time in the popup.
3. Select All Furnaces or type a furnace number.
4. Select the report output folder.
5. Click Generate Excel Report.
6. The program confirms the record count and generated workbook path.
EXPLANATION
-----------
GUI mode uses only Python's built-in tkinter library. pyodbc reads SQL Server
without a pandas/SQLAlchemy warning. pandas prepares timestamps, and openpyxl
formats the Excel report. Command-line mode remains available for WinCC.
================================================================================
"""
import argparse
import os
import sys
import tkinter as tk
from datetime import datetime, time, timedelta
from pathlib import Path
from tkinter import filedialog, messagebox, ttk
import pandas as pd
import pyodbc
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
SQL_SERVER = r"SOFTWELL\WINCC"
DATABASE = "SQF_DB"
DRIVER = "{ODBC Driver 17 for SQL Server}"
def application_folder():
"""Return the EXE folder when compiled, otherwise the Python-file folder."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
DEFAULT_OUTPUT_FOLDER = application_folder() / "SQF_Reports"
def parse_datetime(value, end_of_day=False):
"""Accept YYYY-MM-DD or YYYY-MM-DD HH:MM[:SS]."""
try:
parsed = datetime.fromisoformat(value.strip())
except ValueError as error:
raise ValueError(
"Use YYYY-MM-DD HH:MM:SS, for example 2026-07-27 14:30:00"
) from error
if len(value.strip()) == 10:
parsed = datetime.combine(
parsed.date(),
time(23, 59, 59) if end_of_day else time.min,
)
return parsed
def create_connection():
"""Create a Windows-authenticated SQL Server connection."""
return pyodbc.connect(
f"Driver={DRIVER};Server={SQL_SERVER};Database={DATABASE};"
"Trusted_Connection=yes;TrustServerCertificate=yes;"
)
def get_filtered_data(connection, start_datetime, end_datetime, sqf_no=None):
"""Read the date range, then apply the exact time range."""
query = """
SELECT
[DT], [TM], [SQF_No], [ChargeNo],
[Event_From], [Event_To],
[Temp_Set], [Temp_Act],
[Cp_Set], [Cp_Act],
[Oil_Set], [Oil_Act],
[Jacket_Set], [Jacket_Act],
[Fan_Status]
FROM [dbo].[tblEvent]
WHERE [DT] >= ? AND [DT] < ?
"""
params = [start_datetime.date(), end_datetime.date() + timedelta(days=1)]
if sqf_no is not None:
query += " AND [SQF_No] = ?"
params.append(sqf_no)
query += " ORDER BY [DT] DESC, [TM] DESC;"
cursor = connection.cursor()
cursor.execute(query, params)
columns = [column[0] for column in cursor.description]
dataframe = pd.DataFrame.from_records(cursor.fetchall(), columns=columns)
if dataframe.empty:
return dataframe
date_values = pd.to_datetime(dataframe["DT"], errors="coerce")
time_values = pd.to_timedelta(dataframe["TM"].astype(str), errors="coerce")
combined = date_values.dt.normalize() + time_values
dataframe.insert(
0,
"Event_Timestamp",
combined.where(combined.notna(), date_values),
)
return dataframe[
dataframe["Event_Timestamp"].between(start_datetime, end_datetime)
].reset_index(drop=True)
def export_to_excel(dataframe, output_folder, start_datetime, end_datetime, sqf_no):
"""Create a timestamped, formatted Excel report."""
output_folder.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%d-%m-%Y_%H-%M-%S")
furnace_text = "All" if sqf_no is None else f"SQF-{sqf_no}"
output_file = output_folder / f"SQF_Filtered_Report_{furnace_text}_{stamp}.xlsx"
with pd.ExcelWriter(output_file, engine="openpyxl") as writer:
dataframe.to_excel(
writer,
sheet_name="Filtered Report",
index=False,
startrow=4,
)
worksheet = writer.sheets["Filtered Report"]
# Report title
worksheet["A1"] = "SQF FURNACE EVENT REPORT"
worksheet["A1"].font = Font(bold=True, size=16, color="FFFFFF")
worksheet["A1"].fill = PatternFill("solid", fgColor="1F4E78")
worksheet.merge_cells(
start_row=1,
start_column=1,
end_row=1,
end_column=max(1, len(dataframe.columns)),
)
# Filter summary
worksheet["A2"] = (
f"Period: {start_datetime:%Y-%m-%d %H:%M:%S} to "
f"{end_datetime:%Y-%m-%d %H:%M:%S}"
)
worksheet["A3"] = f"Furnace: {furnace_text} | Records: {len(dataframe)}"
# Data header row
header_row = 5
header_fill = PatternFill("solid", fgColor="F4B183")
for cell in worksheet[header_row]:
cell.font = Font(bold=True)
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center")
worksheet.freeze_panes = "A6"
worksheet.auto_filter.ref = worksheet.dimensions
# Row 1 contains merged title cells. Use the numeric column position
# instead of column_cells[0].column_letter, because merged cells do not
# provide the column_letter attribute.
for column_number, column_cells in enumerate(worksheet.columns, start=1):
letter = get_column_letter(column_number)
width = max(len(str(cell.value or "")) for cell in column_cells) + 3
worksheet.column_dimensions[letter].width = min(width, 35)
for row in worksheet.iter_rows(min_row=6):
if isinstance(row[0].value, datetime):
row[0].number_format = "DD-MM-YYYY HH:MM:SS"
return output_file
def generate_report(start_text, end_text, furnace_text, output_text):
"""Validate filters, query SQL Server, and generate the workbook."""
start_datetime = parse_datetime(start_text)
end_datetime = parse_datetime(end_text, end_of_day=True)
if end_datetime < start_datetime:
raise ValueError("End date/time must be after start date/time.")
furnace_value = furnace_text.strip()
sqf_no = None if furnace_value in {"", "All Furnaces"} else int(furnace_value)
output_folder = Path(output_text).expanduser().resolve()
with create_connection() as connection:
dataframe = get_filtered_data(
connection,
start_datetime,
end_datetime,
sqf_no,
)
output_file = export_to_excel(
dataframe,
output_folder,
start_datetime,
end_datetime,
sqf_no,
)
return output_file, len(dataframe)
def run_popup():
"""Open the operator filter popup."""
root = tk.Tk()
root.title("SQF Filtered Excel Report")
root.geometry("650x370")
root.resizable(False, False)
frame = ttk.Frame(root, padding=22)
frame.pack(fill="both", expand=True)
ttk.Label(
frame,
text="SQF Furnace Report Filter",
font=("Segoe UI", 16, "bold"),
).grid(row=0, column=0, columnspan=3, pady=(0, 20))
now = datetime.now()
start_var = tk.StringVar(
value=(now - timedelta(days=7)).strftime("%Y-%m-%d 00:00:00")
)
end_var = tk.StringVar(value=now.strftime("%Y-%m-%d 23:59:59"))
furnace_var = tk.StringVar(value="All Furnaces")
output_var = tk.StringVar(value=str(DEFAULT_OUTPUT_FOLDER))
status_var = tk.StringVar(value="Select filters and click Generate Excel Report.")
labels = [
("Start date/time", start_var),
("End date/time", end_var),
]
for row_number, (label, variable) in enumerate(labels, start=1):
ttk.Label(frame, text=label).grid(row=row_number, column=0, sticky="w", pady=7)
ttk.Entry(frame, textvariable=variable, width=36).grid(
row=row_number,
column=1,
columnspan=2,
sticky="ew",
pady=7,
)
ttk.Label(frame, text="Furnace").grid(row=3, column=0, sticky="w", pady=7)
furnace_box = ttk.Combobox(
frame,
textvariable=furnace_var,
values=["All Furnaces", "1", "2", "3"],
width=33,
)
furnace_box.grid(row=3, column=1, columnspan=2, sticky="ew", pady=7)
ttk.Label(frame, text="Output folder").grid(row=4, column=0, sticky="w", pady=7)
ttk.Entry(frame, textvariable=output_var, width=36).grid(
row=4,
column=1,
sticky="ew",
pady=7,
)
def browse_folder():
selected = filedialog.askdirectory(initialdir=output_var.get())
if selected:
output_var.set(selected)
ttk.Button(frame, text="Browse...", command=browse_folder).grid(
row=4,
column=2,
padx=(8, 0),
pady=7,
)
def on_generate():
try:
status_var.set("Generating report, please wait...")
root.update_idletasks()
output_file, record_count = generate_report(
start_var.get(),
end_var.get(),
furnace_var.get(),
output_var.get(),
)
status_var.set(f"Completed: {record_count} records")
messagebox.showinfo(
"Report Generated",
f"Report generated successfully.\n\n"
f"Records: {record_count}\nFile:\n{output_file}",
)
if messagebox.askyesno(
"Open Report",
"Do you want to open the Excel report now?",
):
os.startfile(output_file)
except Exception as error:
status_var.set("Report generation failed.")
messagebox.showerror("Report Error", str(error))
ttk.Button(
frame,
text="Generate Excel Report",
command=on_generate,
).grid(row=5, column=0, columnspan=3, pady=(20, 10), ipadx=30, ipady=5)
ttk.Label(frame, textvariable=status_var, foreground="#1F4E78").grid(
row=6,
column=0,
columnspan=3,
)
frame.columnconfigure(1, weight=1)
root.mainloop()
def run_command_line():
"""Generate a report from WinCC or a command prompt."""
parser = argparse.ArgumentParser(description="Generate an SQF Excel report.")
parser.add_argument("start", help='YYYY-MM-DD or "YYYY-MM-DD HH:MM:SS"')
parser.add_argument("end", help='YYYY-MM-DD or "YYYY-MM-DD HH:MM:SS"')
parser.add_argument("sqf_no", nargs="?", help="Optional furnace number")
parser.add_argument("--output", default=str(DEFAULT_OUTPUT_FOLDER))
args = parser.parse_args()
output_file, count = generate_report(
args.start,
args.end,
args.sqf_no or "All Furnaces",
args.output,
)
print(f"Exported {count} records to:\n{output_file}")
def main():
if len(sys.argv) == 1:
run_popup()
else:
run_command_line()
if __name__ == "__main__":
main()Lab Manual 11 — Run the Complete Program End to End
12 minObjective
Run the finished program from source in both modes before compiling, so any failure is a Python problem rather than a PyInstaller one.
Procedure
- Create the project folder and a virtual environment.
- Install pyodbc, pandas, openpyxl and pyinstaller.
- Save the Section 11 listing as
softwell_report.py. - Edit only three constants:
SQL_SERVER,DATABASEandDRIVER. - Run the popup mode, then the command-line mode, then build the EXE.
What each line does
| Code | Meaning in this lab |
|---|---|
| python -m venv .venv | An isolated environment. PyInstaller bundles whatever this environment holds, so keeping it minimal keeps the EXE smaller. |
| pip install pyodbc pandas openpyxl pyinstaller | All four. tkinter and argparse ship with Python and need no install. |
| SQL_SERVER / DATABASE / DRIVER | The only three values that change between sites. Everything else is derived at runtime. |
| DEFAULT_OUTPUT_FOLDER | Derived from application_folder(), so it follows the EXE rather than the working directory. |
| Source first, then EXE | Debugging a traceback in the console is far easier than debugging a silent --windowed EXE. |
Copy-paste practice code
cd C:\SoftwellLabs\wincc-report
python -m venv .venv
.venv\Scripts\activate
python -m pip install --upgrade pip
python -m pip install pyodbc pandas openpyxl pyinstaller
REM 1. Popup mode
python softwell_report.py
REM 2. Command-line mode
python softwell_report.py 2026-07-20 2026-07-27
REM 3. Build only after both modes pass
python -m PyInstaller --clean --noconfirm --onefile --windowed --name Softwell_Report softwell_report.pyDo not skip steps 1 and 2. Almost every reported “the EXE does not work” turns out to be a script that never worked from source either.
Expected result
Exported 1284 records to:
C:\SoftwellLabs\wincc-report\SQF_Reports\SQF_Filtered_Report_All_19-09-2026_18-11-02.xlsx
21234 INFO: Building EXE from EXE-00.toc completed successfully.Checkpoint
If it fails
| Symptom | What to check |
|---|---|
| ModuleNotFoundError: No module named 'pandas' | The environment is not active, or pip installed into a different Python. Run where python and confirm it points inside .venv. |
| The popup opens but Generate does nothing | An exception is being shown in the message box. Read it — it is usually the SQL connection or the date format. |
| Exported 0 records | The date range holds no data, or the time filter removed everything. Lab 5 isolates which. |
| The EXE behaves differently from the script | Almost always a path assumption. Build every path from application_folder(). |
| tkinter fails to import | Python was installed without tcl/tk. Reinstall with that option enabled. |
12. Troubleshooting and Production Improvements
| Symptom | Likely cause | Check / correction |
|---|---|---|
| Button does nothing | Wrong path, blocked script or action not attached | Test absolute path and WinCC action diagnostics |
| EXE works manually but not from WinCC | Different user/context or desktop permissions | Test as Runtime identity; inspect SQL/folder rights |
| Popup opens twice | Duplicate entry point or double launch | Use corrected source and add single-instance protection |
| ODBC driver not found | Driver missing/architecture mismatch | Install and verify the required Microsoft driver |
| Login failed | Runtime Windows account lacks SQF_DB access | Grant approved read-only rights to that identity |
| No records found | Range/furnace excludes data or DT/TM parsing failed | Verify source values and filter boundaries |
| Excel PermissionError | Folder denied, file locked or security software | Verify local folder rights and close locked files |
| EXE startup is slow | One-file extraction and large pandas bundle | Consider --onedir after controlled testing |
| Antivirus quarantines EXE | Unsigned or unapproved generated binary | Use organization signing, allowlisting and change control |
Recommended production upgrades
- Add a single-instance lock so one operator cannot open several report windows.
- Run database/export work in a worker thread so the GUI remains responsive.
- Use a single indexed
datetime2column and exact SQL range filtering. - Load server/database/output configuration from a controlled config file.
- Add application logging with timestamps, filters, row count, duration and errors.
- Validate furnace selection against an allowlist.
- Write to a temporary XLSX and rename after validation.
- Digitally sign the final EXE and document the checksum/version.
Commissioning Checklist: WinCC Button to Excel Report
Site test- Use an approved test/commissioning WinCC project and SQL dataset.
- Keep a backup of the graphics picture and action.
- Confirm the deployed EXE checksum and rollback path.
- Estimated time: 35 minutes.
Prove standalone operation
Run the EXE locally as the WinCC Runtime user and generate a known short-period report.
Configure and test the button
Add the Softwell_Report button and VBScript action, activate Runtime and click once.
Verify filters and report data
Test all furnaces, one furnace, date-only input and exact start/end timestamps. Reconcile results with an approved SQL query.
Test failure and recovery
Test no-data, SQL unavailable, invalid filter and output-permission cases; then restore normal conditions.
Related Python and SQL Server Tutorials
Continue through the Softwell Python–SQL Server learning path:
- Python modules, classes and objects
- Python–SQL Server data type mapping
- Create a SQL Server database and table with Python
- Insert data into SQL Server with Python
- Read SQL Server data into pandas
- Complete Python SQL Server CRUD tutorial
- Export SQL Server data to Excel
- Create a Python EXE for WinCC Excel reports
Frequently asked questions
How does the WinCC button open the EXE?
A Graphics Designer VBScript click action creates WScript.Shell and runs the fixed absolute path to Softwell_Report.exe without waiting for it to finish.
Does the WinCC computer need Python installed?
Not for a properly built standalone PyInstaller EXE. The target still needs the required ODBC driver, SQL/network access, filesystem permissions and endpoint-security approval.
How are date and time filters applied?
The popup validates the inputs, SQL Server returns the broad date range using bound parameters, and pandas combines DT and TM into Event_Timestamp for the exact range filter.
Why does the EXE crash with an error about column_letter?
The column-width loop read column_cells[0].column_letter, but row 1 holds merged title cells and a MergedCell has no such attribute. Use get_column_letter(column_number) from the enumerate index instead.
Should I build with --onefile or as a folder?
--onefile is easier to deploy but unpacks to a temp folder on every start, so it is slower and more likely to be flagged by antivirus. A folder build starts faster and is usually the better choice on a Runtime station.
Why do reports appear in a Temp folder instead of next to the EXE?
A relative path was used. Inside a onefile EXE the working directory is the PyInstaller extraction folder, so every path must be built from application_folder().
Does WinCC freeze while the report is generated?
Not if the third argument of shell.Run is False. That tells Windows not to wait, so Runtime stays responsive.
Can the button pass a date range instead of opening the popup?
Yes. The program runs headless whenever arguments are supplied, so the VBScript can build a command line such as Softwell_Report.exe 2026-07-20 2026-07-27 2.
The EXE works for me but not for the operator. Why?
WinCC Runtime executes under a different Windows account. That account needs read and execute on the EXE, modify on the report folder, and its own SQL Server login with SELECT permission.