Python Implementation of the CoronaWarnApp (CWA) Event Registration

Overview

Python implementation of the Corona-Warn-App (CWA) Event Registration

GitHub PyPI

This is an implementation of the Protocol used to generate event and location QR codes for the Corona-Warn-App (CWA) as described in Corona-Warn-App: Documentation – Event Registration - Summary.

This is not an official implementation – use it at your own risk (as far as that's possible, these days…).

State

The Interface described in the Document is implemented, the undocumented pieces (Public Key Value, Seed Length, Versions etc.) have been taken from the Open Source iOS Client Application. As far as I know the interface has been fully implemented, but without an actual positive Corona Test there is no way to do an End-to-End verification.

Usage

Use as follows:

#!/usr/bin/env python3

import io
from datetime import datetime, time, timezone

import cwa_qr
import qrcode.image.svg

# Construct Event-Descriptor
event_description = cwa_qr.CwaEventDescription()
event_description.location_description = 'Zuhause'
event_description.location_address = 'Gau-Odernheim'
event_description.start_date_time = datetime(2021, 4, 25, 8, 0).astimezone(timezone.utc)
event_description.end_date_time = datetime(2021, 4, 25, 18, 0).astimezone(timezone.utc)
event_description.location_type = cwa_qr.lowlevel.LOCATION_TYPE_PERMANENT_WORKPLACE
event_description.default_check_in_length_in_minutes = 4 * 60

# Renew QR-Code every night at 4:00
event_description.seed = cwa_qr.rollover_date(datetime.now(), time(4, 0))

# Generate QR-Code
qr = cwa_qr.generate_qr_code(event_description)

# Render QR-Code to PNG-File
img = qr.make_image(fill_color="black", back_color="white")
img.save('example.png')
print("generated example.png")

# Render QR-Code to PNG BytesIO-Object for further usage
img_bytes = io.BytesIO()
img.save(img_bytes)
print(len(img_bytes.getvalue()), " bytes of png")

# Render QR-Code to SVG-File
svg = qr.make_image(image_factory=qrcode.image.svg.SvgPathFillImage)
svg.save('example.svg')

# Render QR-Code to SVG BytesIO-Object for further usage
svg_bytes = io.BytesIO()
svg.save(svg_bytes)
print(len(svg_bytes.getvalue()), " bytes of svg")

CwaEventDescription

  • location_description: Description of the Location, Optional, String, max 100 Characters
  • location_address: Address of the Location, Optional, String, max 100 Characters
  • start_date_time: Start of the Event, Optional, datetime in UTC
  • end_date_time: End of the Event, Optional, datetime in UTC Caution, QR-Codes generated with different start/end times will have different Event-IDs and not warn users that have checked in with the other Code. Do not use datetime.now() for start/end-date. For repeating Events use cwa_qr.rollover_date to get a defined rollover.
  • location_type: Type of the Location, Optional, one of
    • cwa.lowlevel.LOCATION_TYPE_UNSPECIFIED = 0
    • cwa.lowlevel.LOCATION_TYPE_PERMANENT_OTHER = 1
    • cwa.lowlevel.LOCATION_TYPE_TEMPORARY_OTHER = 2
    • cwa.lowlevel.LOCATION_TYPE_PERMANENT_RETAIL = 3
    • cwa.lowlevel.LOCATION_TYPE_PERMANENT_FOOD_SERVICE = 4
    • cwa.lowlevel.LOCATION_TYPE_PERMANENT_CRAFT = 5
    • cwa.lowlevel.LOCATION_TYPE_PERMANENT_WORKPLACE = 6
    • cwa.lowlevel.LOCATION_TYPE_PERMANENT_EDUCATIONAL_INSTITUTION = 7
    • cwa.lowlevel.LOCATION_TYPE_PERMANENT_PUBLIC_BUILDING = 8
    • cwa.lowlevel.LOCATION_TYPE_TEMPORARY_CULTURAL_EVENT = 9
    • cwa.lowlevel.LOCATION_TYPE_TEMPORARY_CLUB_ACTIVITY = 10
    • cwa.lowlevel.LOCATION_TYPE_TEMPORARY_PRIVATE_EVENT = 11
    • cwa.lowlevel.LOCATION_TYPE_TEMPORARY_WORSHIP_SERVICE = 12
  • default_check_in_length_in_minutes: Default Check-out time in minutes, Optional
  • seed: Seed to rotate the QR-Code, Optional, [str, bytes, int, float, date, datetime] or None (Default). Use with caution & read below! If unsure, leave blank.

Rotating QR-Codes

From the Documentation:

Profiling of Venues

An adversary can collect this information for a single venue by scanning the QR code and extracting and storing the data. To mitigate the risk, CWA encourages owners to regularly generate new QR codes for their venues. The more frequent QR codes are updated, the more difficult it is to keep a central database with venue data up-to-date. However, a new QR code should only be generated when no visitor is at the event or location, because visitors can only warn each other with the same QR code.

From an Application-Developers point of view, special care must be taken to decide if and when QR codes should be changed. A naive approach, i.e. changing the QR-Code on every call, would render the complete Warning-Chain totally useless without anyone noticing. Therefore, the Default of this Library as of 2021/04/26 is to not seed the QR-Codes with random values. This results in every QR-Code being generated without an explicit Seed to be identical, which minimizes the Risk of having QR-Codes that do not warn users as expected at the increased risk of profiling of Venues.

As an Application-Developer you are encouraged to ask you user if and when they want their QR-Codes to change and explain to them that they should only rotate their Codes when they are sure that nobody is at the location or in the venue for at least 30 Minutes, to allow airborne particles to settle or get filtered out. Do not make assumptions regarding a good time to rotate QR-Codes (i.e. always at 4:00 am) because they will fail so warn people in some important Situations (nightclubs, hotels, night-shift working) without anyone noticing.

To disable rotation of QR-Codes, specify None as the Seed (Default behaviour).

The Library also gives you a utility to allow rotating QR-Codes at a given time of the day. Please make sure to also integrate some kind of Secret into the seed, to prevent an adversary from calculating future QR-Codes. The Secret must stay constant over time, or the resulting QR-Codes will not correctly trigger warnings.

import io
from datetime import datetime, time

import cwa_qr

# Construct Event-Descriptor
event_description = cwa_qr.CwaEventDescription()
# …
seed_date = cwa_qr.rollover_date(datetime.now(), time(4, 0))
event_description.seed = "Some Secret" + str(seed_date)

this will keep the date-based seed until 4:00 am on the next day and only then roll over to the next day. See test_rollover.py for an in-depth look at the rollover code.

Python 2/3

This library supports Python 3.6+, however there is a backport to Python 2 available at https://github.com/MaZderMind/cwa-qr/tree/py2

Comments
  • Add poster generating capabilities

    Add poster generating capabilities

    This PR adds a small helper class/function to generate CWA-posters.

    Two layouts are included - the official portrait one as well as a landscape-version which has been inspired by the first one.

    Since I'm not very good at graphics stuff, this might not be the best way to achieve the desired result - so feel free to discard this PR if it's not up to your standards :-)

    (Since I ìsorted the imports of the files I touched with this PR, some sorting of the imports has occurred)

    opened by pc-coholic 5
  • Change Default to not Seed QR-Codes and provide Utility for custom rollover Seed

    Change Default to not Seed QR-Codes and provide Utility for custom rollover Seed

    From the Documentation:

    Profiling of Venues

    An adversary can collect this information for a single venue by scanning the QR code and extracting and storing the data. To mitigate the risk, CWA encourages owners to regularly generate new QR codes for their venues. The more frequent QR codes are updated, the more difficult it is to keep a central database with venue data up-to-date. However, a new QR code should only be generated when no visitor is at the event or location, because visitors can only warn each other with the same QR code.

    From an Application-Developers point of view, special care must be taken to decide if and when QR codes should be changed. A naive approach, i.e. changing the QR-Code on every call, would render the complete Warning-Chain totally useless without anyone noticing. Therefore, the Default of this Library changed in this PR is to not seed the QR-Codes with random values. This results in every QR-Code being generated without an explicit Seed to be identical, which minimizes the Risk of having QR-Codes that do not warn users as expected at the increased risk of profiling of Venues.

    As an Application-Developer you are encouraged to ask you user if and when they want their QR-Codes to change and explain to them that they should only rotate their Codes when they are sure that nobody is at the location or in the venue for at least 30 Minutes, to allow airborne particles to settle or get filtered out. Do not make assumptions regarding a good time to rotate QR-Codes (i.e. always at 4:00 am) because they will fail so warn people in some important Situations (nightclubs, hotels, night-shift working) without anyone noticing.

    To disable rotation of QR-Codes, specify None as the Seed (Default behaviour). This PR gives authors a utility to allow rotating QR-Codes at a time of day specified by your user:

    import io
    from datetime import datetime, time
    
    import cwa
    
    # Construct Event-Descriptor
    eventDescription = cwa.CwaEventDescription()
    # …
    eventDescription.seed = cwa.rolloverDate(datetime.now(), time(4, 0))
    

    this will keep the date-based seed until 4:00 am on the next day and only then roll over to the next day. See test_rollover.py for an in-depth look at the rollover code.

    opened by MaZderMind 4
  • Bump pillow from 8.3.2 to 9.0.0

    Bump pillow from 8.3.2 to 9.0.0

    Bumps pillow from 8.3.2 to 9.0.0.

    Release notes

    Sourced from pillow's releases.

    9.0.0

    https://pillow.readthedocs.io/en/stable/releasenotes/9.0.0.html

    Changes

    ... (truncated)

    Changelog

    Sourced from pillow's changelog.

    9.0.0 (2022-01-02)

    • Restrict builtins for ImageMath.eval(). CVE-2022-22817 #5923 [radarhere]

    • Ensure JpegImagePlugin stops at the end of a truncated file #5921 [radarhere]

    • Fixed ImagePath.Path array handling. CVE-2022-22815, CVE-2022-22816 #5920 [radarhere]

    • Remove consecutive duplicate tiles that only differ by their offset #5919 [radarhere]

    • Improved I;16 operations on big endian #5901 [radarhere]

    • Limit quantized palette to number of colors #5879 [radarhere]

    • Fixed palette index for zeroed color in FASTOCTREE quantize #5869 [radarhere]

    • When saving RGBA to GIF, make use of first transparent palette entry #5859 [radarhere]

    • Pass SAMPLEFORMAT to libtiff #5848 [radarhere]

    • Added rounding when converting P and PA #5824 [radarhere]

    • Improved putdata() documentation and data handling #5910 [radarhere]

    • Exclude carriage return in PDF regex to help prevent ReDoS #5912 [hugovk]

    • Fixed freeing pointer in ImageDraw.Outline.transform #5909 [radarhere]

    • Added ImageShow support for xdg-open #5897 [m-shinder, radarhere]

    • Support 16-bit grayscale ImageQt conversion #5856 [cmbruns, radarhere]

    • Convert subsequent GIF frames to RGB or RGBA #5857 [radarhere]

    ... (truncated)

    Commits

    Dependabot compatibility score

    Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


    Dependabot commands and options

    You can trigger Dependabot actions by commenting on this PR:

    • @dependabot rebase will rebase this PR
    • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
    • @dependabot merge will merge this PR after your CI passes on it
    • @dependabot squash and merge will squash and merge this PR after your CI passes on it
    • @dependabot cancel merge will cancel a previously requested merge and block automerging
    • @dependabot reopen will reopen this PR if it is closed
    • @dependabot close will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
    • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
    • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
    • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    • @dependabot use these labels will set the current labels as the default for future PRs for this repo and language
    • @dependabot use these reviewers will set the current reviewers as the default for future PRs for this repo and language
    • @dependabot use these assignees will set the current assignees as the default for future PRs for this repo and language
    • @dependabot use this milestone will set the current milestone as the default for future PRs for this repo and language

    You can disable automated security fix PRs for this repo from the Security Alerts page.

    dependencies 
    opened by dependabot[bot] 1
  • CWA-Posters

    CWA-Posters

    This builds upon and supersedes #9

    This PR adds a small helper class/function to generate CWA-posters. Two layouts are included - the official portrait one as well as a landscape-version which has been inspired by the first one.

    Thanks to @pc-coholic for doing the hard work ;)

    opened by MaZderMind 1
  • Add explicit exports to the main module

    Add explicit exports to the main module

    I'm not a 100% sure on this since this isn't on PyPI and I just copied the cwa module as suggested in the README. However, I think the example in the README is wrong when it says

    import cwa
    …
    eventDescription = cwa.CwaEventDescription()
    

    It should either be

    from cwa import cwa
    

    Or the cwa main module should expose the public API directly, as this PR proposes.

    opened by raphaelm 1
  • Fix README

    Fix README

    The README file contains some typos and capitalization mistakes. This PR aims to fix that in order to make the file more aesthetically pleasing.

    Many thanks for creating this tool.

    opened by fynngodau 1
  • Loosen version requirements

    Loosen version requirements

    It makes sense for a Python project to pin specific versions, but it can be annoying for a dependency. In our case, we could not use cwa-qr in an environment where we also use protobuf 3.19, even though they work fine together. I therefore propose loosening the constraint.

    opened by raphaelm 0
  • Bump pillow from 8.2.0 to 8.3.2

    Bump pillow from 8.2.0 to 8.3.2

    Bumps pillow from 8.2.0 to 8.3.2.

    Release notes

    Sourced from pillow's releases.

    8.3.2

    https://pillow.readthedocs.io/en/stable/releasenotes/8.3.2.html

    Security

    • CVE-2021-23437 Raise ValueError if color specifier is too long [hugovk, radarhere]

    • Fix 6-byte OOB read in FliDecode [wiredfool]

    Python 3.10 wheels

    • Add support for Python 3.10 #5569, #5570 [hugovk, radarhere]

    Fixed regressions

    • Ensure TIFF RowsPerStrip is multiple of 8 for JPEG compression #5588 [kmilos, radarhere]

    • Updates for ImagePalette channel order #5599 [radarhere]

    • Hide FriBiDi shim symbols to avoid conflict with real FriBiDi library #5651 [nulano]

    8.3.1

    https://pillow.readthedocs.io/en/stable/releasenotes/8.3.1.html

    Changes

    8.3.0

    https://pillow.readthedocs.io/en/stable/releasenotes/8.3.0.html

    Changes

    ... (truncated)

    Changelog

    Sourced from pillow's changelog.

    8.3.2 (2021-09-02)

    • CVE-2021-23437 Raise ValueError if color specifier is too long [hugovk, radarhere]

    • Fix 6-byte OOB read in FliDecode [wiredfool]

    • Add support for Python 3.10 #5569, #5570 [hugovk, radarhere]

    • Ensure TIFF RowsPerStrip is multiple of 8 for JPEG compression #5588 [kmilos, radarhere]

    • Updates for ImagePalette channel order #5599 [radarhere]

    • Hide FriBiDi shim symbols to avoid conflict with real FriBiDi library #5651 [nulano]

    8.3.1 (2021-07-06)

    • Catch OSError when checking if fp is sys.stdout #5585 [radarhere]

    • Handle removing orientation from alternate types of EXIF data #5584 [radarhere]

    • Make Image.array take optional dtype argument #5572 [t-vi, radarhere]

    8.3.0 (2021-07-01)

    • Use snprintf instead of sprintf. CVE-2021-34552 #5567 [radarhere]

    • Limit TIFF strip size when saving with LibTIFF #5514 [kmilos]

    • Allow ICNS save on all operating systems #4526 [baletu, radarhere, newpanjing, hugovk]

    • De-zigzag JPEG's DQT when loading; deprecate convert_dict_qtables #4989 [gofr, radarhere]

    • Replaced xml.etree.ElementTree #5565 [radarhere]

    ... (truncated)

    Commits
    • 8013f13 8.3.2 version bump
    • 23c7ca8 Update CHANGES.rst
    • 8450366 Update release notes
    • a0afe89 Update test case
    • 9e08eb8 Raise ValueError if color specifier is too long
    • bd5cf7d FLI tests for Oss-fuzz crash.
    • 94a0cf1 Fix 6-byte OOB read in FliDecode
    • cece64f Add 8.3.2 (2021-09-02) [CI skip]
    • e422386 Add release notes for Pillow 8.3.2
    • 08dcbb8 Pillow 8.3.2 supports Python 3.10 [ci skip]
    • Additional commits viewable in compare view

    Dependabot compatibility score

    Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


    Dependabot commands and options

    You can trigger Dependabot actions by commenting on this PR:

    • @dependabot rebase will rebase this PR
    • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
    • @dependabot merge will merge this PR after your CI passes on it
    • @dependabot squash and merge will squash and merge this PR after your CI passes on it
    • @dependabot cancel merge will cancel a previously requested merge and block automerging
    • @dependabot reopen will reopen this PR if it is closed
    • @dependabot close will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
    • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
    • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
    • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
    • @dependabot use these labels will set the current labels as the default for future PRs for this repo and language
    • @dependabot use these reviewers will set the current reviewers as the default for future PRs for this repo and language
    • @dependabot use these assignees will set the current assignees as the default for future PRs for this repo and language
    • @dependabot use this milestone will set the current milestone as the default for future PRs for this repo and language

    You can disable automated security fix PRs for this repo from the Security Alerts page.

    dependencies 
    opened by dependabot[bot] 0
  • Change API to Python-Style

    Change API to Python-Style

    I do too much java… therefore the API does not use snake_case for methods and variables. This should be changed before a tagged & published release.

    Also the Module-Name should be changed to "cwa_qr" to match the Github-Name.

    opened by MaZderMind 0
  • qr-code alignment on poster

    qr-code alignment on poster

    First of all: Thank you! That's great work.

    I have just a little design issue: example_full.py generates a svg_bytes with 70198 bytes length. With that amount of data the qr-code is aligned into the center of the poster/portrait.svg

    In my test i had smaller amounts of data in my event_description. And with e.g. only 61444 bytes you get a smaller qr-code and its alignment is a little more left then it should be. Perhaps it would help to check svg_bytes.getbuffer().nbytes to determine the necessary scale dynamically

    (i guess with the landscape template it is the same, but i didn't test it.) grafik

    opened by wolf128058 1
Releases(v1.2.2)
Owner
MaZderMind
Program everything.
MaZderMind
Scalable Optical Flow-based Image Montaging and Alignment

SOFIMA SOFIMA (Scalable Optical Flow-based Image Montaging and Alignment) is a tool for stitching, aligning and warping large 2d, 3d and 4d microscopy

Google Research 16 Dec 21, 2022
PyTorch implementation of Interpretable Explanations of Black Boxes by Meaningful Perturbation

PyTorch implementation of Interpretable Explanations of Black Boxes by Meaningful Perturbation The paper: https://arxiv.org/abs/1704.03296 What makes

Jacob Gildenblat 322 Dec 17, 2022
Certis - Certis, A High-Quality Backtesting Engine

Certis - Backtesting For y'all Certis is a powerful, lightweight, simple backtes

Yeachan-Heo 46 Oct 30, 2022
Code & Data for Enhancing Photorealism Enhancement

Code & Data for Enhancing Photorealism Enhancement

Intel ISL (Intel Intelligent Systems Lab) 1.1k Jan 08, 2023
TF2 implementation of knowledge distillation using the "function matching" hypothesis from the paper Knowledge distillation: A good teacher is patient and consistent by Beyer et al.

FunMatch-Distillation TF2 implementation of knowledge distillation using the "function matching" hypothesis from the paper Knowledge distillation: A g

Sayak Paul 67 Dec 20, 2022
TensorFlow 101: Introduction to Deep Learning for Python Within TensorFlow

TensorFlow 101: Introduction to Deep Learning I have worked all my life in Machine Learning, and I've never seen one algorithm knock over its benchmar

Sefik Ilkin Serengil 896 Jan 04, 2023
code for paper "Not All Unlabeled Data are Equal: Learning to Weight Data in Semi-supervised Learning" by Zhongzheng Ren*, Raymond A. Yeh*, Alexander G. Schwing.

Not All Unlabeled Data are Equal: Learning to Weight Data in Semi-supervised Learning Overview This code is for paper: Not All Unlabeled Data are Equa

Jason Ren 22 Nov 23, 2022
Turn based roguelike in python

pyTB Turn based roguelike in python Documentation can be found here: http://mcgillij.github.io/pyTB/index.html Screenshot Dependencies Written in Pyth

Jason McGillivray 4 Sep 29, 2022
Bayesian Inference Tools in Python

BayesPy Bayesian Inference Tools in Python Our goal is, given the discrete outcomes of events, estimate the distribution of categories. Using gradient

Max Sklar 99 Dec 14, 2022
Mosaic of Object-centric Images as Scene-centric Images (MosaicOS) for long-tailed object detection and instance segmentation.

MosaicOS Mosaic of Object-centric Images as Scene-centric Images (MosaicOS) for long-tailed object detection and instance segmentation. Introduction M

Cheng Zhang 27 Oct 12, 2022
The code for our CVPR paper PISE: Person Image Synthesis and Editing with Decoupled GAN, Project Page, supp.

PISE The code for our CVPR paper PISE: Person Image Synthesis and Editing with Decoupled GAN, Project Page, supp. Requirement conda create -n pise pyt

jinszhang 110 Nov 21, 2022
Code for the KDD 2021 paper 'Filtration Curves for Graph Representation'

Filtration Curves for Graph Representation This repository provides the code from the KDD'21 paper Filtration Curves for Graph Representation. Depende

Machine Learning and Computational Biology Lab 16 Oct 16, 2022
CPF: Learning a Contact Potential Field to Model the Hand-object Interaction

Contact Potential Field This repo contains model, demo, and test codes of our paper: CPF: Learning a Contact Potential Field to Model the Hand-object

Lixin YANG 99 Dec 26, 2022
A self-supervised 3D representation learning framework named viewpoint bottleneck.

Pointly-supervised 3D Scene Parsing with Viewpoint Bottleneck Paper Created by Liyi Luo, Beiwen Tian, Hao Zhao and Guyue Zhou from Institute for AI In

63 Aug 11, 2022
Official code for 'Robust Siamese Object Tracking for Unmanned Aerial Manipulator' and offical introduction to UAMT100 benchmark

SiamSA: Robust Siamese Object Tracking for Unmanned Aerial Manipulator Demo video 📹 Our video on Youtube and bilibili demonstrates the evaluation of

Intelligent Vision for Robotics in Complex Environment 12 Dec 18, 2022
The source code for the Cutoff data augmentation approach proposed in this paper: "A Simple but Tough-to-Beat Data Augmentation Approach for Natural Language Understanding and Generation".

Cutoff: A Simple Data Augmentation Approach for Natural Language This repository contains source code necessary to reproduce the results presented in

Dinghan Shen 49 Dec 22, 2022
The first dataset on shadow generation for the foreground object in real-world scenes.

Object-Shadow-Generation-Dataset-DESOBA Object Shadow Generation is to deal with the shadow inconsistency between the foreground object and the backgr

BCMI 105 Dec 30, 2022
"Domain Adaptive Semantic Segmentation without Source Data" (ACM MM 2021)

LDBE Pytorch implementation for two papers (the paper will be released soon): "Domain Adaptive Semantic Segmentation without Source Data", ACM MM2021.

benfour 16 Sep 28, 2022
Official Repository for "Robust On-Policy Data Collection for Data Efficient Policy Evaluation" (NeurIPS 2021 Workshop on OfflineRL).

Robust On-Policy Data Collection for Data-Efficient Policy Evaluation Source code of Robust On-Policy Data Collection for Data-Efficient Policy Evalua

Autonomous Agents Research Group (University of Edinburgh) 2 Oct 09, 2022
Irrigation controller for Home Assistant

Irrigation Unlimited This integration is for irrigation systems large and small. It can offer some complex arrangements without large and messy script

Robert Cook 176 Jan 02, 2023