Repository: pymeasure/pymeasure

Issue:
Use of dynamic properties in instrument "channel"
Just discovered that dynamic properties are not supported in some implementation of instrument "channel". For example, if we consider the following class: https://github.com/pymeasure/pymeasure/blob/d7547d6cf79b85d8bee96958067537095c116df9/pymeasure/instruments/newport/esp300.py#L130
The properties declared inside the class can be defined dynamic by adding the appropriate parameter, but they will not behave as dynamic properties and the original value cannot be overridden.
I think this needs to be taken into account when some guidelines for Channels implementation are provided (see https://github.com/pymeasure/pymeasure/issues/416#issuecomment-1025090693)

Discussion:
bmoneke: I added a section regarding Channel implementation to the "adding instruments" documentation.

As an example, I changed the AWG401x to this Channel implementation (Although I did not change the properties from `Instrument.control` to `Channel.control`).

More instruments could me changed, but this instrument is the only urgent one, I think.
msmttchr: > I added a section regarding Channel implementation to the "adding instruments" documentation.
> 
> As an example, I changed the AWG401x to this Channel implementation (Although I did not change the properties from `Instrument.control` to `Channel.control`).
> 
> More instruments could me changed, but this instrument is the only urgent one, I think.

I think this is very good initiative and I would like to provide some feedback asap.
I think we could agree the core changes (mainly instrumens.py) first and then propagate them to the relevant instruments.
This just to avoid making a lot of changes before the strategy is agreed.

bmoneke: I did not intend to change all the other instruments, only AWG401x, which uses `Instrument` as a channel crutch.

I can change the other ones though.
bmoneke: Thanks for your appreciation @msmttchr 

Right now, you can nest channels into channels (as the AWG401x_AWG instrument does with its Sequencer). Basically `Instrument` is a special channel, which talks to an adapter. To indicate, that a channel may depend on an instrument or a channel, we could rename Channel's `instrument` parameter to `parent` (which may be an instrument or another channel).

Access
------

Talking about access: I like the idea, that you can set an 'active channel' and afterwards, you can use that channel's properties without specifying the channel, something like:
```
inst.chA.voltage = 5  # specifying the channel directly
inst.active_channel = "A"
inst.voltage = 5  # the same as the first line
```
I don't know yet, how to implement it best.

I like your list access (`inst[3]`), as that allows addressing several channels in a for loop.

> class Base should support class attributes to define channels, so both Instrument and Channel will support channels.
It should also instantiate channels as appropriate and store them.

How did you imagine these class attributes? Like the property factories? Some instruments have to instantiate the channels at runtime (see AWG401x, which reads its device and creates an appropriate number of channels).
I could imagine a method (I guess it has to pertain to the instance), which accepts a channel class and parameters and adds it such to the parent (instrument or channel), that the item and attribute access to this channel works.
msmttchr: > I'm not sure, whether the "thing" (instrument or channel) should be in the channel list or not. I like the idea to go through all channels of an instrument in that form:
> 
> ```
> for channel in instrument:
>     channel.voltage = 5
> ```
> 
> If the instrument itself were channel 0, this code would fail.
> 

Indeed you are right in this.
My idea was to model instrument and associated channels as a sort of list data structure, and, as such, the instrument itself would be index 0.
To be more accurate, we are trying to implement a tree.
I have no strong motivation, but I feel it make sense to have the root at index 0.
In such case, your example code would be:
`for channel in instrument[1:]:`

but I acknowledge that this is less natural.

> Regarding the attribute name: I'd prefer to separate the keyword (e.g. channel) from the name (e.g. 1 or A) with an underscore, for example `channel_A`. The word "channel", however, is quite long, especially if you have a channel in a channel: `my_instrument.channel_volts.channel_A.voltage = 7.3`. For that reason I think, "ch" might be better.
> 
> I made an example implementation of channel access in the last commit (to talk about)

Again I think you are right, my idea was to let the user decide the name, but, of course, the name is used in the commands, so there's no much freedom here except to obey on what the instrument expects.
In summary, for each channel, instrument implementors should provide:
- attribute access prefix (eg. ch or any string)
- channel name as expected by instrument commands
- class defining the channel implementation
- kwargs for extra parameters to be passed in class instantiation

Coming back to my sketch code, we should have something like:
```
class MyInstrument (Instrument):
    channels= {
         'name' : ('A', MyChannel, kwargs),
         'name' : ('B', MyChannel, kwargs),
     }
.
.
.
```
which start to become a little bit heavy.
Access would be `inst.name_A` or `instr.name['A']` or both.
bmoneke: > attribute access prefix (eg. ch or any string)

I would not allow different attribute prefixes, in order to keep them easily distinguishable in the whole codebase. Otherwise one implementor will call it "ch", one "channel", one "c". Some with underscore, some without...
That way, everybody knows, that these attributes are forbidden.
Thinking about, I prefer "ch_", to keep it short.

> To be more accurate, we are trying to implement a tree.

I don't get, why we have to have the instance as its own child ( self.inst[0] == self) in order to form a tree.

> Access would be inst.name_A or instr.name['A'] or both.

At one hand, I like the idea to access the channels also with the name in dictionary style. On the other hand, the multitude of options (`inst[0] == inst.name_A == inst.name['A']`) will increase the different usages in the codebase. I like the list (inst[0]) and the attribute (inst.name_A)


One question regarding implementation of `inst.name_A`. We could do it in the `__getattribute__` method or with `setattr`. I think the latter is better, because explicit and offers autocompletion...
bmoneke: Btw, I don't know, why the doctests fail with a recursion in adapter.write.
If I run them locally, they succeed.
msmttchr: > > attribute access prefix (eg. ch or any string)
> 
> I would not allow different attribute prefixes, in order to keep them easily distinguishable in the whole codebase. Otherwise one implementor will call it "ch", one "channel", one "c". Some with underscore, some without... That way, everybody knows, that these attributes are forbidden. Thinking about, I prefer "ch_", to keep it short.

Here, I do not think that the string defined by customer, would impact the code base, but just how to access the channel from user point of view.
Referring to Keythley 2306 implementation:
```
    def __init__(self, adapter, **kwargs):
        super().__init__(
            adapter,
            "Keithley 2306",
            **kwargs
        )
        self.ch1 = BatteryChannel(self, 1)
        self.ch2 = Channel(self, 2)
        self.relay1 = Relay(self, 1)
        self.relay2 = Relay(self, 2)
        self.relay3 = Relay(self, 3)
        self.relay4 = Relay(self, 4)
```

In this case, implementors decided to access the first two channels as:
```
inst.ch1
inst.ch2
OR
inst.ch(n) # n=1.2

inst.relay1
inst.relay2
inst.relay3
inst.relay4
OR
inst.relay(n) # n=1,2,3,4

```
Of course, if we force implementors to use only 'ch', this will prevent implementation equivalent to this in terms of user interface.

> 
> > To be more accurate, we are trying to implement a tree.
> 
> I don't get, why we have to have the instance as its own child ( self.inst[0] == self) in order to form a tree.

Ok, I think your point is fair.

> 
> > Access would be inst.name_A or instr.name['A'] or both.
> 
> At one hand, I like the idea to access the channels also with the name in dictionary style. On the other hand, the multitude of options (`inst[0] == inst.name_A == inst.name['A']`) will increase the different usages in the codebase. I like the list (inst[0]) and the attribute (inst.name_A)
> 
> One question regarding implementation of `inst.name_A`. We could do it in the `__getattribute__` method or with `setattr`. I think the latter is better, because explicit and offers autocompletion...

I am sorry, I am aware on how to implement this in `__getattribute__`, but not sure how to do it in the `setattr`.
Could You please clarify?

bmoneke: > In this case, implementors decided to access the first two channels as:
> 
> ```
> inst.ch1
> inst.ch2
> OR
> inst.ch(n) # n=1.2
> 
> inst.relay1
> inst.relay2
> inst.relay3
> inst.relay4
> OR
> inst.relay(n) # n=1,2,3,4
> ```
> 
> Of course, if we force implementors to use only 'ch', this will prevent implementation equivalent to this in terms of user interface.

Oh, good point. 


> I am sorry, I am aware on how to implement this in `__getattribute__`, but not sure how to do it in the `setattr`. Could You please clarify?

You can set an attribute with `setattr(object, name, value)`:
```
channel = SomeChannel()
setattr(self, "channel5", channel)
self.channel5 == channel
```
Here we created some channel and in the second step, we added the attribute "channel5" to the instance. Afterwards the call to `self.channel5` refers to the newly created channel. With `getattr(obj, name)` and `delattr(obj, name)` you can get / delete the attribute `name` of an object `obj`.
bilderbuchi: Thank you for starting this PR! A couple of notes from the discussion so far and a skimming of the PR.
* The discussion in #364 shows some edge/special cases of channel-based instruments, might be good to keep in mind for the design work/when deciding what to test for.
* Name for the common class/parent of Instrument and Channel: What about `CommonBase`?
* `self._channels` could be `self._children` -- this is nicely symmetric to the notion of `parent`, and also echoes conventional lingo for trees. As it's a private attribute, we don't need this to be evoke "channels" for the user interface.
* I agree with calling that init argument `parent` 
* I'm not sure I see the need for this `self.root` attribute?
* Good point to "standardise" the API for channel access with `instr.ch_XXX`. I agree that it's important to avoid having ch1, ch_1, channel1, etc. 
* I would not have included list-based access, but I see the point regarding looping through all channels. 
* One thing I'd caution is that we should avoid making our Instrument behaviour too "varied" (Benedikt, you'll know "eierlegende Wollmilchsau")
  * so far we had class-like behaviour. We recently added dynamic properties with "magic attributes", which muddies the waters a bit. Now, we want to make Instrument behave like a list, too (and maybe like a dict, too?). 
  * At some point it will be hard to intuit the behaviour of our class. For example, what happens to list-based access for an Instrument *without channels*?
  * What do you think about implementing a generator for looping through channels? One could to ` for c in instr.channels:` for looping, which feels idiomatic and clear to me. We could also just put the list-based access in a public `channels` attribute, for people who need that instead of name-based access.
  * I'd hold off on the "active channel" feature, this feels a bit arbitrary/confusing. Also, it multiplies the amount of properties a class can access, not sure if we want to increase that even further. Also, what about instruments that have an attribute that is identical to an attribute of that instrument's channel -- that would collide? We can always do that later/in specific instruments if needed.
  * That ch{1-2} and relay{1-4} instrument is a nice edge case! Maybe we can/should make that autogenerated ` ch_XXX` attributes optional (on by default), so implementors can deal with their edge cases manually if they need?
bilderbuchi: Also, thank you for doing the leg-work here (again), so that we can come in and ~~criticise~~ provide feedback with a high-level view in mind! :grinning: At least in my case, this is quite the enabling aspect for getting these important features done (because it frees me from quite a chunk of work which would otherwise mean that it does not happen)!
LongnoseRob: Very interesting PR and discussion.
While SMUs or power supplies with several output-channels are cleary a use-case, how should this automatic naming work on an modern MSO  (Mixed Signal Oscilloscope) with for example: 4 real (analog) inputs, several math-channels, several reference-channels and maybe even 8-16 digital cannels?
`ch1-4` for the analog, `ch5-8` for the math, vs. `analog1..4`, `math1..4`, `ref1..4`, `digital0-15` 

How to handle in the case that there is a device-family with 2 and 4 analog input channels, but otherwise same interface-specification?



bmoneke: Thinking more about the access via attribute, list dictionary, I prefer to have the attribute ch_A and a list channels.

The attribute, you can do manually as well, but a method `add_channel` enables consistent behavior (attribute and list, both with default naming scheme). Until now every naming and approach (attributes or one list) differs slightly.

Whoever needs more "channel" types, might subclass `add_channel` to add relays or sources or analog channels... 
bilderbuchi: >  They can subclass "add_channel" in order to make their additional channel group.

This should answer @LongnoseRob's question about MSO handling, right?
bilderbuchi: > I think it is best, to make the channel list (or dictionary) a public property and forego the getitem acces, which means `inst.ch[5]` instead of `inst[5]`. 

:+1: 

> A dictionary offers the possibility to store the channels by name (or "id" grin ). Or we just store them as attributes, without any dictionary/list?

> Thinking more about the access via attribute, list dictionary, I prefer to have the attribute ch_A and a list channels. As it is now public, I'd name this list `self.channels` and not `self.children` as proposed earlier.

Agreed! 
If one needs the the names when iterating through the channels, every object has its `id` property, anyway.
So:
* a list `Instrument.channels` that probably gets created when `add_channel` is called the first time.
* `Instrument.add_channel` takes care of populating that list and adding the respective attributes
* If possible, `Instrument.add_channel` has an arg to choose another name if `ch_XX` is not appropriate.

We need to put a note in the docs and changelog that we have a new public reserved name `Instrument.channels`.

bilderbuchi: > Btw, I don't know, why the doctests fail with a recursion in adapter.write.
If I run them locally, they succeed.

Good news, I can reproduce this locally (`make doctest` in `docs/` on Ubuntu 22.04). I'll try to find out what's going on.


bmoneke: > > Btw, I don't know, why the doctests fail with a recursion in adapter.write.
> > If I run them locally, they succeed.
> 
> Good news, I can reproduce this locally (`make doctest` in `docs/` on Ubuntu 22.04). I'll try to find out what's going on.

Mybe its the forgotten colon, that it breaks it somehow?
bmoneke: I split the files (only content of the last commit) and implemented the changes renaming `add_child` which allows to use different list names (e.g. relays) and different prefixes (for e.g. rl_1, or ai_1).
bilderbuchi: > > > Btw, I don't know, why the doctests fail with a recursion in adapter.write.
> > > If I run them locally, they succeed.
> > 
> > 
> > Good news, I can reproduce this locally (`make doctest` in `docs/` on Ubuntu 22.04). I'll try to find out what's going on.
> 
> Mybe its the forgotten colon, that it breaks it somehow?

My leading theory is that it's an interaction with `FakeInstrument`. Remember, up top, we do `from pymeasure.instruments.fakes import FakeInstrument as Instrument` to enable faked responses for doctesting the interaction with an instrument. 
I suspect something goes wrong because `FakeInstrument` does not correctly account for the new structure. 
It's hard to debug of course, because Sphinx creates the files it doctests on the fly from rst sources -- try letting your debugger see those. :-P
bilderbuchi: > My leading theory is that it's an interaction with `FakeInstrument`. Remember, up top, we do `from pymeasure.instruments.fakes import FakeInstrument as Instrument` to enable faked responses for doctesting the interaction with an instrument. I suspect something goes wrong because `FakeInstrument` does not correctly account for the new structure. It's hard to debug of course, because Sphinx creates the files it doctests on the fly from rst sources -- try letting your debugger see those. :-P

OK, that theory was wrong. Somehow, in that doctest, the instrument init ends up with its own class as the `adapter` (you can tell because the protocoladapter ends up in `name`), and this sets up a nice recursion:
![image](https://user-images.githubusercontent.com/327442/198258914-4b506db4-8558-4dc7-b078-ceab5c17a3fb.png)
I'm not yet _sure_ why that happens -- if that is something in our code, or something that breaks because of `doctest` magic...
bilderbuchi: > I'll propose a refactoring of the doctests in that file to have a cleaner separation between the different faking methods.

Sorry, the Github web interface did not let me PR against your branch, I had to commit directly to the branch.
bilderbuchi: Just so we don't forget: Can you please add a todo to make sure #364 is closed by this PR, and the cases discussed therein are accounted for as appropriate?
msmttchr: > > I am sorry, I am aware on how to implement this in `__getattribute__`, but not sure how to do it in the `setattr`. Could You please clarify?
> 
> You can set an attribute with `setattr(object, name, value)`:
> 
> ```
> channel = SomeChannel()
> setattr(self, "channel5", channel)
> self.channel5 == channel
> ```
> 
> Here we created some channel and in the second step, we added the attribute "channel5" to the instance. Afterwards the call to `self.channel5` refers to the newly created channel. With `getattr(obj, name)` and `delattr(obj, name)` you can get / delete the attribute `name` of an object `obj`.

I see, here the drawback is that a lot of attribute are added to the class (luckly we do not pay for it :) ), but probably less difficult to debug.
bmoneke: The previous force push is a rebase on master.
In the following commits I made small changes (group to collection, filename to snake_case...) and improved the documentation and tests according to the current state and the issues raised in #364.

The last force push is a tiny change (spaces instead of tabs) in the documentation in order to prevent another doctest runaway (I don't know why it happened again).

Still open questions:

- [ ] Add some kind of class to class connection?
- [ ] SCPI default channel communication?
msmttchr: Il Gio 3 Nov 2022, 09:00 Benedikt Moneke ***@***.***> ha
scritto:

> ***@***.**** commented on this pull request.
> ------------------------------
>
> In pymeasure/instruments/channel.py
> <https://github.com/pymeasure/pymeasure/pull/718#discussion_r1012587455>:
>
> > +    def write_bytes(self, content, **kwargs):
> +        """Write the bytes `content` to the instrument."""
> +        self.parent.write_bytes(content, **kwargs)
> +
> +    def read(self, **kwargs):
> +        """Read up to (excluding) `read_termination` or the whole read buffer."""
> +        return self.parent.read(**kwargs)
> +
> +    def read_bytes(self, count, **kwargs):
> +        """Read a certain number of bytes from the instrument.
> +
> +        :param int count: Number of bytes to read. A value of -1 indicates to
> +            read the whole read buffer.
> +        :param kwargs: Keyword arguments for the adapter.
> +        :returns bytes: Bytes response of the instrument (including termination).
> +        """
> +        return self.parent.read_bytes(count, **kwargs)
> +
> +    def write_binary_values(self, command, values, *args, **kwargs):
> +        """Write binary values to the instrument.
> +
> +        :param command: Command to send.
> +        :param values: The values to transmit.
> +        :param \\*args, \\**kwargs: Further arguments to hand to the Adapter.
> +        """
> +        self.parent.write_binary_values(command.format(ch=self.id), values, *args, **kwargs)
> +
> +    def read_binary_values(self, **kwargs):
> +        """Read binary values from the instrument."""
> +        return self.parent.read_binary_values(**kwargs)
> +
> +    # Communication functions
> +    def wait_for(self, query_delay=0):
> +        """Wait for some time. Used by 'ask' to wait before reading.
> +
> +        :param query_delay: Delay between writing and reading in seconds.
> +        """
> +        self.parent.wait_for(query_delay)
>
> Here, I am sorry I see little value in defining the default name ch, I
> mean it does not harm, but really the name should be derived from what is
> written on the instrument documentation or front panel.
>
> I prefer a uniform channel naming scheme (e.g. all channels have a "ch_"
> prefix) independent of the manufacturers naming (trace, channel,
> source...), such that I do not have to worry, how the individual
> manufacturer names this feature.
> This allows to switch more easily one device for another one
>
> As a pymeasure user, you're going to know, that any channel starts with
> "ch_" and you can make the transfer from the displayed "traceA" to "ch_A"
> in the code, if need arises. (The ids like "A" or "1" etc. should match
> though).
>
My opinion is very basic: is it a trace ? Call it trace. I understand this
is allowed with current implementation, so implemeters can use the default
or use a more appropriate name.

> —
> Reply to this email directly, view it on GitHub
> <https://github.com/pymeasure/pymeasure/pull/718#discussion_r1012587455>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AJAXVWWYDTR2DBSOZ5BFMCDWGNWJFANCNFSM6AAAAAARL2SWTY>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index de2363fa..b8aaa471 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,3 +1,9 @@
+Upcoming version
+================
+New adapter and instrument mechanics
+------------------------------------
+- Channel class added. Instrument.channels and Instrument.ch_X (X is any channel name) are reserved for channel implementations.
+
 Version 0.11.0 (2022-11-19)
 ===========================
 Main items of this new release:
diff --git a/docs/api/instruments/bkprecision/bkprecision9130b.rst b/docs/api/instruments/bkprecision/bkprecision9130b.rst
index 81b88f79..81b5cbb5 100644
--- a/docs/api/instruments/bkprecision/bkprecision9130b.rst
+++ b/docs/api/instruments/bkprecision/bkprecision9130b.rst
@@ -5,5 +5,3 @@ BK Precision 9130B DC Power Supply
 .. autoclass:: pymeasure.instruments.bkprecision.BKPrecision9130B
     :members:
     :show-inheritance:
-    :inherited-members:
-    :exclude-members: ask, control, clear, measurement, read, setting, values, write
\ No newline at end of file
diff --git a/docs/api/instruments/instruments.rst b/docs/api/instruments/instruments.rst
index 9eaa2f91..cbdc366c 100644
--- a/docs/api/instruments/instruments.rst
+++ b/docs/api/instruments/instruments.rst
@@ -2,9 +2,15 @@
 Instrument classes
 ##################
 
+.. autoclass:: pymeasure.instruments.common_base.CommonBase
+    :members:
+
 .. autoclass:: pymeasure.instruments.Instrument
     :members:
 
+.. autoclass:: pymeasure.instruments.Channel
+    :members:
+
 .. autoclass:: pymeasure.instruments.fakes.FakeInstrument
     :members:
     :show-inheritance:
diff --git a/docs/dev/adding_instruments.rst b/docs/dev/adding_instruments.rst
index adb5eac1..be68d6c5 100644
--- a/docs/dev/adding_instruments.rst
+++ b/docs/dev/adding_instruments.rst
@@ -8,6 +8,20 @@ You can make a significant contribution to PyMeasure by adding a new instrument
 
 Before getting started, become familiar with the :doc:`contributing work-flow <contribute>` for PyMeasure, which steps through the process of adding a new feature (like an instrument) to the development version of the source code. This section will describe how to lay out your instrument code.
 
+.. testsetup::
+
+    # Behind the scene, replace Instrument with FakeInstrument to enable
+    # doctesting simple usage cases (default doctest group)
+    from pymeasure.instruments.fakes import FakeInstrument as Instrument
+
+.. testsetup:: with-protocol-tests
+
+    # If we want to run protocol tests on doctest code, we need to use a
+    # separate doctest "group" and a different set of imports.
+    # See https://www.sphinx-doc.org/en/master/usage/extensions/doctest.html
+    from pymeasure.instruments import Instrument, Channel
+    from pymeasure.test import expected_protocol
+
 File structure
 ==============
 
@@ -82,13 +96,6 @@ The most basic instrument, for our "Extreme 5000" example starts like this:
     #
 
     # from pymeasure.instruments import Instrument
-    
-.. testcode::
-    :hide:
-
-    # Behind the scene, replace Instrument with FakeInstrument to enable
-    # doctesting all this
-    from pymeasure.instruments.fakes import FakeInstrument as Instrument
 
 This is a minimal instrument definition:
 
@@ -299,9 +306,10 @@ In PyMeasure, `Python properties`_ are the preferred method for dealing with var
 
 The property factories
 **********************
-PyMeasure comes with three central convenience factory functions for making properties for classes: :func:`Instrument.control <pymeasure.instruments.Instrument.control>`, :func:`Instrument.measurement <pymeasure.instruments.Instrument.measurement>`, and :func:`Instrument.setting <pymeasure.instruments.Instrument.setting>`.
+PyMeasure comes with three central convenience factory functions for making properties for classes: :func:`CommonBase.control <pymeasure.instruments.common_base.CommonBase.control>`, :func:`CommonBase.measurement <pymeasure.instruments.common_base.CommonBase.measurement>`, and :func:`CommonBase.setting <pymeasure.instruments.common_base.CommonBase.setting>`.
+You can call them, however, as :code:`Instrument.control`, :code:`Instrument.measurement`, and :code:`Instrument.setting`.
 
-The :func:`Instrument.measurement <pymeasure.instruments.Instrument.measurement>` function returns a property that can only read values from an instrument.
+The :func:`Instrument.measurement <pymeasure.instruments.common_base.CommonBase.measurement>` function returns a property that can only read values from an instrument.
 For example, if our "Extreme 5000" has the :code:`*IDN?` command, we can write the following property to be added after the :code:`def __init__` line in our above example class, or added to the class after the fact as in the code here:
 
 .. _Python properties: https://docs.python.org/3/howto/descriptor.html#properties
@@ -330,7 +338,7 @@ When we use this property we will get the temperature of the reaction cell.
     >>> extreme.cell_temp  # Sends ":TEMP?" to the device
     127.2
 
-The :func:`Instrument.control <pymeasure.instruments.Instrument.control>` function extends this behavior by creating a property that you can read and set. For example, if our "Extreme 5000" has the :code:`:VOLT?` and :code:`:VOLT <float>` commands that are in Volts, we can write the following property.
+The :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>` function extends this behavior by creating a property that you can read and set. For example, if our "Extreme 5000" has the :code:`:VOLT?` and :code:`:VOLT <float>` commands that are in Volts, we can write the following property.
 
 .. testcode::
 
@@ -352,9 +360,9 @@ We can use this property to set the voltage to 100 mV, which will send the appro
     >>> extreme.voltage              # Sends ":VOLT?" to query for the current value
     0.1
 
-Finally, the :func:`Instrument.setting <pymeasure.instruments.Instrument.setting>` function can only set, but not read values.
+Finally, the :func:`Instrument.setting <pymeasure.instruments.common_base.CommonBase.setting>` function can only set, but not read values.
 
-Using the :func:`Instrument.control <pymeasure.instruments.Instrument.control>`, :func:`Instrument.measurement <pymeasure.instruments.Instrument.measurement>`, and :func:`Instrument.control <pymeasure.instruments.Instrument.control>` functions, you can create a number of properties for basic measurements and controls.
+Using the :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>`, :func:`Instrument.measurement <pymeasure.instruments.common_base.CommonBase.measurement>`, and :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>` functions, you can create a number of properties for basic measurements and controls.
 
 The next sections detail additional features of the property factories.
 These allow you to write properties that cover specific ranges, or that have to map between a real value to one used in the command. Furthermore it is shown how to perform more complex processing of return values from your device.
@@ -363,7 +371,7 @@ These allow you to write properties that cover specific ranges, or that have to
 
 Restricting values with validators
 **********************************
-Many GPIB/SCPI commands are more restrictive than our basic examples above. The :func:`Instrument.control <pymeasure.instruments.Instrument.control>` function has the ability to encode these restrictions using :mod:`validators <pymeasure.instruments.validators>`. A validator is a function that takes a value and a set of values, and returns a valid value or raises an exception. There are a number of pre-defined validators in :mod:`pymeasure.instruments.validators` that should cover most situations. We will cover the four basic types here.
+Many GPIB/SCPI commands are more restrictive than our basic examples above. The :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>` function has the ability to encode these restrictions using :mod:`validators <pymeasure.instruments.validators>`. A validator is a function that takes a value and a set of values, and returns a valid value or raises an exception. There are a number of pre-defined validators in :mod:`pymeasure.instruments.validators` that should cover most situations. We will cover the four basic types here.
 
 In the examples below we assume you have imported the validators.
 
@@ -372,7 +380,7 @@ In the examples below we assume you have imported the validators.
 
     from pymeasure.instruments.validators import strict_discrete_set, strict_range, truncated_range, truncated_discrete_set
 
-In many situations you will also need to process the return string in order to extract the wanted quantity or process a value before sending it to the device. The :func:`Instrument.control <pymeasure.instruments.Instrument.control>`, :func:`Instrument.measurement <pymeasure.instruments.Instrument.measurement>` and :func:`Instrument.setting <pymeasure.instruments.Instrument.setting>` function also provide means to achieve this.
+In many situations you will also need to process the return string in order to extract the wanted quantity or process a value before sending it to the device. The :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>`, :func:`Instrument.measurement <pymeasure.instruments.common_base.CommonBase.measurement>` and :func:`Instrument.setting <pymeasure.instruments.common_base.CommonBase.setting>` function also provide means to achieve this.
 
 In a restricted range
 ---------------------
@@ -452,7 +460,7 @@ Now we can set the voltage range, which will automatically truncate to an approp
 Mapping values
 **************
 
-Now that you are familiar with the validators, you can additionally use maps to satisfy instruments which require non-physical values. The :code:`map_values` argument of :func:`Instrument.control <pymeasure.instruments.Instrument.control>` enables this feature.
+Now that you are familiar with the validators, you can additionally use maps to satisfy instruments which require non-physical values. The :code:`map_values` argument of :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>` enables this feature.
 
 If your set of values is a list, then the command will use the index of the list. For example, if our "Extreme 5000" instead has a :code:`:RANG <integer>`, where 0, 1, and 2 correspond to 10 mV, 100 mV, and 1 V, then we can use the following control.
 
@@ -524,7 +532,7 @@ The dictionary now maps the keys to specific values. The values and keys can be
     >>> extreme.channel
     'Y'
 
-As you have seen, the :func:`Instrument.control <pymeasure.instruments.Instrument.control>` function can be significantly extended by using validators and maps.
+As you have seen, the :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>` function can be significantly extended by using validators and maps.
 
 .. _boolean-properties:
 
@@ -564,7 +572,7 @@ Good names for boolean properties are chosen such that they could also be a yes/
 Processing of set values
 ************************
 
-The :func:`Instrument.control <pymeasure.instruments.Instrument.control>`, and :func:`Instrument.setting <pymeasure.instruments.Instrument.setting>` allow a keyword argument `set_process` which must be a function that takes a value after validation and performs processing before value mapping. This function must return the processed value. This can be typically used for unit conversions as in the following example:
+The :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>`, and :func:`Instrument.setting <pymeasure.instruments.common_base.CommonBase.setting>` allow a keyword argument `set_process` which must be a function that takes a value after validation and performs processing before value mapping. This function must return the processed value. This can be typically used for unit conversions as in the following example:
 
 
 .. testcode::
@@ -585,7 +593,7 @@ The :func:`Instrument.control <pymeasure.instruments.Instrument.control>`, and :
 Processing of return values
 ***************************
 
-Similar to `set_process` the :func:`Instrument.control <pymeasure.instruments.Instrument.control>`, and :func:`Instrument.measurement <pymeasure.instruments.Instrument.measurement>` functions allow a `get_process` argument which if specified must be a function that takes a value and performs processing before value mapping. The function must return the processed value. In analogy to the example above this can be used for example for unit conversion:
+Similar to `set_process` the :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>`, and :func:`Instrument.measurement <pymeasure.instruments.common_base.CommonBase.measurement>` functions allow a `get_process` argument which if specified must be a function that takes a value and performs processing before value mapping. The function must return the processed value. In analogy to the example above this can be used for example for unit conversion:
 
 .. testcode::
 
@@ -639,7 +647,7 @@ Another use-case of `set-process`, `get-process` is conversion from/to a :code:`
         get_process=lambda v: float(v.replace('nF', ''))
     )
 
-The same can be also achieved by the `preprocess_reply` keyword argument to :func:`Instrument.control <pymeasure.instruments.Instrument.control>` or :func:`Instrument.measurement <pymeasure.instruments.Instrument.measurement>`. This function is forwarded to :func:`Adapter.values <pymeasure.adapters.values>` and runs directly after receiving the reply from the device. One can therefore take advantage of the built in casting abilities and simplify the code accordingly:
+The same can be also achieved by the `preprocess_reply` keyword argument to :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>` or :func:`Instrument.measurement <pymeasure.instruments.common_base.CommonBase.measurement>`. This function is forwarded to :func:`Adapter.values <pymeasure.adapters.values>` and runs directly after receiving the reply from the device. One can therefore take advantage of the built in casting abilities and simplify the code accordingly:
 
 .. testcode::
 
@@ -657,20 +665,20 @@ If you need to tweak
 * the :code:`set_command` string immediately before the value to set is inserted via string formatting (:code:`%g` etc.), or
 * the :code:`get_command` string before sending it to the device,
 
-use the :code:`command_process` parameter of :meth:`~pymeasure.instruments.Instrument.control`.
+use the :code:`command_process` parameter of :meth:`~pymeasure.instruments.common_base.CommonBase.control`.
 
 Note that there is only one parameter for both setting and getting, so the utility of this is probably limited.
 Note also that for adding e.g. channel identifiers, there are other, more preferable methods.
 
 Checking the instrument for errors
 **********************************
-If you need to separately ask your instrument about its error state after getting/setting, use the parameters :code:`check_get_errors` and :code:`check_set_errors` of :meth:`~pymeasure.instruments.Instrument.control`, respectively.
+If you need to separately ask your instrument about its error state after getting/setting, use the parameters :code:`check_get_errors` and :code:`check_set_errors` of :meth:`~pymeasure.instruments.common_base.CommonBase.control`, respectively.
 If those are enabled, the method :meth:`~pymeasure.instruments.Instrument.check_errors` will be called after device communication has concluded.
 
 Using multiple values
 *********************
 Seldomly, you might need to send/receive multiple values in one command.
-The :func:`Instrument.control <pymeasure.instruments.Instrument.control>` function can be used with multiple values at one time, passed as a tuple. Say, we may set voltages and frequencies in our "Extreme 5000", and the the commands for this are :code:`:VOLTFREQ?` and :code:`:VOLTFREQ <float>,<float>`, we could use the following property:
+The :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>` function can be used with multiple values at one time, passed as a tuple. Say, we may set voltages and frequencies in our "Extreme 5000", and the the commands for this are :code:`:VOLTFREQ?` and :code:`:VOLTFREQ <float>,<float>`, we could use the following property:
 
 .. testcode::
 
@@ -709,7 +717,7 @@ Pay attention *not* to inadvertently define other class attribute or instance at
 .. note::
    To clearly distinguish these special attributes from normal class/instance attributes, they can only be set, not read. 
 
-The mechanism works for all the parameters in properties, except :code:`dynamic` and :code:`docs` -- see :func:`Instrument.control <pymeasure.instruments.Instrument.control>`, :func:`Instrument.measurement <pymeasure.instruments.Instrument.measurement>`, :func:`Instrument.setting <pymeasure.instruments.Instrument.setting>`.
+The mechanism works for all the parameters in properties, except :code:`dynamic` and :code:`docs` -- see :func:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>`, :func:`Instrument.measurement <pymeasure.instruments.common_base.CommonBase.measurement>`, :func:`Instrument.setting <pymeasure.instruments.common_base.CommonBase.setting>`.
 
 Dynamic validity range
 ----------------------
@@ -826,6 +834,137 @@ In the above example, :code:`MultimeterA` and :code:`MultimeterB` use a differen
 :code:`MultimeterB` can be defined subclassing :code:`MultimeterA` and just implementing the difference.
 
 
+.. _channels:
+
+Instruments with channels
+=========================
+
+Some instruments, like oscilloscopes and voltage sources, have channels whose commands differ only in the channel name.
+For this case, we have :class:`~pymeasure.instruments.Channel`, which is similar to :class:`~pymeasure.instruments.Instrument` and its property factories, but does expect an :class:`~pymeasure.instruments.Instrument` instance (i.e., a parent instrument) instead of an :class:`~pymeasure.adapters.Adapter` as parameter.
+All the channel communication is routed through the instrument's methods (`write`, `read`, etc.).
+However, :meth:`Channel.insert_id <pymeasure.instruments.Channel.insert_id>` uses `str.format` to insert the channel's id at any occurence of the class attribute :attr:`Channel.placeholder`, which defaults to :code:`"ch"`, in the written commands.
+For example :code:`"Ch{ch}:VOLT?"` will be sent as :code:`"Ch3:VOLT?"` to the device, if the channel's id is "3".
+
+In order to add a channel to an instrument or to another channel (nesting channels is possible), create the channels with the class :class:`~pymeasure.instruments.common_base.CommonBase.ChannelCreator` as class attributes.
+Its constructor accepts a single channel class or list of classes and a list of corresponding ids.
+Instead of lists, you may also use tuples.
+If you give a single class and a list of ids, all channels will be of the same class.
+
+At instrument instantiation, the instrument will add the channels accordingly with the attribute names as a composition of the prefix (default :code:`"ch_"`) and channel id, e.g. the channel with id "A" will be added as attribute :code:`ch_A`.
+Additionally, the channels will be collected in a dictionary with the same name as you used for the `ChannelCreator`.
+Without pressing reasons, call the dictionary :code:`channels` and do not change the default prefix in order to keep the code base homogeneous.
+
+In order to add or remove programatically channels, use the parent's :meth:`~pymeasure.instruments.common_base.CommonBase.add_child`, :meth:`~pymeasure.instruments.common_base.CommonBase.remove_child` methods.
+
+.. testcode:: with-protocol-tests
+
+    class VoltageChannel(Channel):
+        """A channel of the voltage source."""
+
+        voltage = Channel.control(
+            "SOURce{ch}:VOLT?", "SOURce{ch}:VOLT %g",
+            """Control the output voltage of this channel.""",
+        )
+
+    class InstrumentWithChannels(Instrument):
+        """An instrument with channels."""
+        channels = Instrument.ChannelCreator(VoltageChannel, ("A", "B"))
+
+.. testcode:: with-protocol-tests
+    :hide:
+
+    with expected_protocol(InstrumentWithChannels,
+        [("SOURceA:VOLT 1.23", None), ("SOURceB:VOLT?", "4.56")],
+        name="Instrument with Channels",
+    ) as inst:
+        inst.ch_A.voltage = 1.23
+        assert inst.ch_B.voltage == 4.56
+
+If you set the voltage of the first channel of above :class:`ExtremeChannel` instrument with :code:`inst.chA.voltage = 1.23`, the driver sends :code:`"SOURceA:VOLT 1.23"` to the device, supplying the "A" of the channel name.
+The same channel could be addressed with :code:`inst.channels["A"].voltage = 1.23` as well.
+
+
+Channels with fixed prefix
+**************************
+
+If all channel communication is prefixed by a specific command, e.g. :code:`"SOURceA:"` for channel A, you can override the channel's :meth:`insert_id` method.
+That is especially useful, if you have only one channel of that type, e.g. because it defines one function of the instrument vs. another one.
+
+.. testcode:: with-protocol-tests
+
+    class VoltageChannelPrefix(Channel):
+        """A channel of a voltage source, every command has the same prefix."""
+
+        def insert_id(self, command):
+            return f"SOURce{self.id}:{command}"
+
+        voltage = Channel.control(
+            "VOLT?", "VOLT %g",
+            """Control the output voltage of this channel.""",
+        )
+
+.. testcode:: with-protocol-tests
+    :hide:
+
+    class InstrumentWithChannelsPrefix(Instrument):
+        """An instrument with a channel, just for the test."""
+        channels = Instrument.ChannelCreator(VoltageChannelPrefix, "A")
+
+    with expected_protocol(InstrumentWithChannelsPrefix,
+        [("SOURceA:VOLT 1.23", None), ("SOURceA:VOLT?", "1.23")],
+        name="Test",
+    ) as inst:
+        inst.ch_A.voltage = 1.23
+        assert inst.ch_A.voltage == 1.23
+
+This channel class implements the same communication as the previous example, but implements the channel prefix in the :meth:`insert_id` method and not in the individual property (created by :meth:`control`).
+
+
+Collections of different channel types
+**************************************
+
+Some devices have different types of channels. In this case, you can specify a different `collection` and `prefix` parameter.
+
+.. testcode:: with-protocol-tests
+
+    class PowerChannel(Channel):
+        """A channel controlling the power."""
+
+        power = Channel.measurement(
+            "POWER?", """Measure the currently consumed power.""")
+
+    class MultiChannelTypeInstrument(Instrument):
+        """An instrument with two different channel types."""
+        analog = Instrument.ChannelCreator(
+            (VoltageChannel, VoltageChannelPrefix),
+            ("A", "B"),
+            prefix="an_")
+        digital = Instrument.ChannelCreator(VoltageChannel, (0, 1, 2), prefix="di_")
+        power = Instrument.ChannelCreator(PowerChannel, prefix=None)
+
+
+.. testcode:: with-protocol-tests
+    :hide:
+
+    with expected_protocol(MultiChannelTypeInstrument,
+        [("SOURceB:VOLT 1.23", None), ("SOURce2:VOLT?", "4.56")],
+        name="MultiChannelTypeInstrument",
+    ) as inst:
+        inst.an_B.voltage = 1.23
+        assert inst.di_2.voltage == 4.56
+
+
+This instrument has two collections of channels and one single channel.
+The first collection in the dictionary :code:`analog` contains an instance of :class:`VoltageChannel` with the name :code:`an_A` and an instance of :class:`VoltageChannelPrefix` with the name :code:`an_B`.
+The second collection contains three channels of type :class:`VoltageChannel` with the names :code:`di_0, di_1, di_2` in the dictionary :code:`digital`.
+You can address the first channel of the second group either with :code:`inst.di_0` or with :code:`inst.digital[0]`.
+Finally, the instrument has a single channel with the name :code:`power`, as it does not have a prefix.
+
+If you have a single channel category, do not change the default parameters of :class:`~pymeasure.instruments.common_base.CommonBase.ChannelCreator` or :meth:`~pymeasure.instruments.common_base.CommonBase.add_child`, in order to keep the code base homogeneous.
+We expect the default behaviour to be sufficient for most use cases.
+
+
+
 .. _advanced_communication_protocols:
 
 Advanced communication protocols
@@ -843,9 +982,9 @@ The :class:`~pymeasure.adapters.Adapter` exposes :meth:`~pymeasure.adapters.Adap
 For binary data, like waveforms, the adapter provides also :meth:`~pymeasure.adapters.Adapter.write_binary_values` and :meth:`~pymeasure.adapters.Adapter.read_binary_values`, which use the aforementioned methods.
 You do not need to call all these methods directly, instead, you should use the methods of :class:`~pymeasure.instruments.Instrument` with the same name. They call the Adapter for you and keep the code tidy.
 
-Now to :class:`~pymeasure.instruments.Instrument`. The most important methods are :meth:`~pymeasure.instruments.Instrument.write` and :meth:`~pymeasure.instruments.Instrument.read`, as they are the most basic building blocks for the communication. The pymeasure properties (:meth:`Instrument.control <pymeasure.instruments.Instrument.control>` and its derivatives :meth:`Instrument.measurement <pymeasure.instruments.Instrument.measurement>` and :meth:`Instrument.setting <pymeasure.instruments.Instrument.setting>`) and probably most of your methods and properties will call them. In any instrument, :meth:`write` should write a general string command to the device in such a way, that it understands it. Similarly, :meth:`read` should return a string in a general fashion in order to process it further.
+Now to :class:`~pymeasure.instruments.Instrument`. The most important methods are :meth:`~pymeasure.instruments.Instrument.write` and :meth:`~pymeasure.instruments.Instrument.read`, as they are the most basic building blocks for the communication. The pymeasure properties (:meth:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>` and its derivatives :meth:`Instrument.measurement <pymeasure.instruments.common_base.CommonBase.measurement>` and :meth:`Instrument.setting <pymeasure.instruments.common_base.CommonBase.setting>`) and probably most of your methods and properties will call them. In any instrument, :meth:`write` should write a general string command to the device in such a way, that it understands it. Similarly, :meth:`read` should return a string in a general fashion in order to process it further.
 
-The getter of :meth:`Instrument.control <pymeasure.instruments.Instrument.control>` does not call them directly, but via a chain of methods. It calls :meth:`~pymeasure.instruments.Instrument.values` which in turn calls :meth:`~pymeasure.instruments.Instrument.ask` and processes the returned string into understandable values. :meth:`~pymeasure.instruments.Instrument.ask` sends the readout command via :meth:`write`, waits some time if necessary via :meth:`wait_for`, and reads the device response via :meth:`read`.
+The getter of :meth:`Instrument.control <pymeasure.instruments.common_base.CommonBase.control>` does not call them directly, but via a chain of methods. It calls :meth:`~pymeasure.instruments.Instrument.values` which in turn calls :meth:`~pymeasure.instruments.Instrument.ask` and processes the returned string into understandable values. :meth:`~pymeasure.instruments.Instrument.ask` sends the readout command via :meth:`write`, waits some time if necessary via :meth:`wait_for`, and reads the device response via :meth:`read`.
 
 Similarly, :meth:`Instrument.binary_values <pymeasure.instruments.Instrument.binary_values>` sends a command via :meth:`write`, waits with :meth:`wait_till_read`, but reads the response via :meth:`Adapter.read_binary_values <pymeasure.adapters.Adapter.read_binary_values>`.
 
@@ -856,7 +995,7 @@ Adding a device address and adding delay
 Let's look at a simple example for a device, which requires its address as the first three characters and returns the same style. This is straightforward, as :meth:`write` just prepends the device address to the command, and :meth:`read` has to strip it again doing some error checking. Similarly, a checksum could be added.
 Additionally, the device needs some time after it received a command, before it responds, therefore :meth:`wait_for` waits always a certain time span.
 
-.. testcode::
+.. testcode:: with-protocol-tests
 
     class ExtremeCommunication(Instrument):
         """Control the ExtremeCommunication instrument.
@@ -894,7 +1033,8 @@ Additionally, the device needs some time after it received a command, before it
         voltage = Instrument.measurement(
             ":VOLT:?", """Measure the voltage in Volts.""")
 
-.. testcode:: :hide:
+.. testcode:: with-protocol-tests
+    :hide:
 
     with expected_protocol(ExtremeCommunication, [("012:VOLT:?", "01215.5")], address=12
         ) as inst:
@@ -908,7 +1048,7 @@ Bytes communication
 
 Some devices do not expect ASCII strings but raw bytes. In those cases, you can call the :meth:`write_bytes` and :meth:`read_bytes` in your :meth:`write` and :meth:`read` methods. The following example shows an instrument, which has registers to be written and read via bytes sent.
 
-.. testcode::
+.. testcode:: with-protocol-tests
 
     class ExtremeBytes(Instrument):
         """Control the ExtremeBytes instrument with byte-based communication."""
@@ -943,7 +1083,8 @@ Some devices do not expect ASCII strings but raw bytes. In those cases, you can
             """Control the output voltage in mV.""",
         )
 
-.. testcode:: :hide:
+.. testcode:: with-protocol-tests
+    :hide:
 
     with expected_protocol(ExtremeBytes, [(b"\x03\x01\x06\x00\x00\x00\x00\x00\x00\x00\x01", b"\x03\x01\x0f")]) as inst:
         assert inst.voltage == 15
diff --git a/pymeasure/instruments/__init__.py b/pymeasure/instruments/__init__.py
index 3214e826..2dfd9641 100644
--- a/pymeasure/instruments/__init__.py
+++ b/pymeasure/instruments/__init__.py
@@ -23,6 +23,7 @@
 #
 
 from ..errors import RangeError, RangeException
+from .channel import Channel
 from .instrument import Instrument
 from .resources import list_resources
 from .validators import discreteTruncate
diff --git a/pymeasure/instruments/activetechnologies/AWG401x.py b/pymeasure/instruments/activetechnologies/AWG401x.py
index 7cf88047..9ee74861 100644
--- a/pymeasure/instruments/activetechnologies/AWG401x.py
+++ b/pymeasure/instruments/activetechnologies/AWG401x.py
@@ -31,16 +31,21 @@ from collections import abc, namedtuple
 
 import pprint
 
-from pymeasure.instruments import Instrument
+from pymeasure.instruments import Instrument, Channel
 from pymeasure.instruments.validators import strict_discrete_set,\
     strict_range
 
 
-class ChannelBase(Instrument):
+class ChannelBase(Channel):
     """Implementation of a base Active Technologies AWG-4000 channel."""
 
+    def __init__(self, instrument, id):
+        super().__init__(instrument, id)
+
+        self.delay_values = [self.delay_min, self.delay_max]
+
     enabled = Instrument.control(
-        "OUTPut<ch>:STATe?", "OUTPut<ch>:STATe %d",
+        "OUTPut{ch}:STATe?", "OUTPut{ch}:STATe %d",
         """A boolean property that enables or disables the output for the
         specified channel.""",
         validator=strict_discrete_set,
@@ -49,7 +54,7 @@ class ChannelBase(Instrument):
     )
 
     polarity = Instrument.control(
-        "OUTPut<ch>:POLarity?", "OUTPut<ch>:POLarity %s",
+        "OUTPut{ch}:POLarity?", "OUTPut{ch}:POLarity %s",
         """This property inverts the output waveform relative to its average
         value: (High Level – Low Level)/2. NORM for normal, INV for inverted
         """,
@@ -80,28 +85,20 @@ class ChannelBase(Instrument):
         dynamic=True
     )
 
-    def __init__(self, instrument, channel_number):
-        self.instrument = instrument
-        self.channel_number = channel_number
-
-        self._special_names = self._setup_special_names()
-        self.delay_values = [self.delay_min, self.delay_max]
 
-    def values(self, command, **kwargs):
-        return self.instrument.values(
-            command.replace("<ch>", str(self.channel_number)),
-            **kwargs)
+class ChannelAFG(ChannelBase):
+    """Implementation of a Active Technologies AWG-4000 channel in AFG mode."""
 
-    def write(self, command):
-        self.instrument.write(command.replace("<ch>",
-                                              str(self.channel_number)))
+    def __init__(self, instrument, id):
+        super().__init__(instrument, id)
 
+        self.calculate_voltage_range()
+        self.frequency_values = [self.frequency_min, self.frequency_max]
 
-class ChannelAFG(ChannelBase):
-    """Implementation of a Active Technologies AWG-4000 channel in AFG mode."""
+        self.phase_values = [self.phase_min, self.phase_max]
 
     load_impedance = Instrument.control(
-        "OUTPut<ch>:IMPedance?", "OUTPut<ch>:IMPedance %d",
+        "OUTPut{ch}:IMPedance?", "OUTPut{ch}:IMPedance %d",
         """This property sets the output load impedance for the specified
         channel. The specified value is used for amplitude, offset, and
         high/low level settings. You can set the impedance to any value from
@@ -111,7 +108,7 @@ class ChannelAFG(ChannelBase):
     )
 
     output_impedance = Instrument.control(
-        "OUTPut<ch>:LOW:IMPedance?", "OUTPut<ch>:LOW:IMPedance %d",
+        "OUTPut{ch}:LOW:IMPedance?", "OUTPut{ch}:LOW:IMPedance %d",
         """This property sets the instrument output impedance, the possible
         values are: 5 Ohm or 50 Ohm (default).""",
         validator=strict_discrete_set,
@@ -120,7 +117,7 @@ class ChannelAFG(ChannelBase):
     )
 
     shape = Instrument.control(
-        "SOURce<ch>:FUNCtion:SHAPe?", "SOURce<ch>:FUNCtion:SHAPe %s",
+        "SOURce{ch}:FUNCtion:SHAPe?", "SOURce{ch}:FUNCtion:SHAPe %s",
         """This property sets or queries the shape of the carrier waveform.
         Allowed choices depends on the choosen modality, please refer on
         instrument manual. When you set this property with a different value,
@@ -136,13 +133,13 @@ class ChannelAFG(ChannelBase):
     )
 
     # Default delay override
-    delay_get_command = "SOURce<ch>:INITDELay?"
-    delay_set_command = "SOURce<ch>:INITDELay %s"
-    delay_max_get_command = "SOURce<ch>:INITDELay? MAXimum"
-    delay_min_get_command = "SOURce<ch>:INITDELay? MINimum"
+    delay_get_command = "SOURce{ch}:INITDELay?"
+    delay_set_command = "SOURce{ch}:INITDELay %s"
+    delay_max_get_command = "SOURce{ch}:INITDELay? MAXimum"
+    delay_min_get_command = "SOURce{ch}:INITDELay? MINimum"
 
     frequency = Instrument.control(
-        "SOURce<ch>:FREQuency?", "SOURce<ch>:FREQuency %s",
+        "SOURce{ch}:FREQuency?", "SOURce{ch}:FREQuency %s",
         """This property sets or queries the frequency of the output waveform.
         This command is available when the Run Mode is set to any setting other
         than Sweep. The output frequency range setting depends on the type of
@@ -155,19 +152,19 @@ class ChannelAFG(ChannelBase):
     )
 
     frequency_max = Instrument.measurement(
-        "SOURce<ch>:FREQuency? MAXimum",
+        "SOURce{ch}:FREQuency? MAXimum",
         """This property queries the maximum frequency that can be set to the
         output waveform."""
     )
 
     frequency_min = Instrument.measurement(
-        "SOURce<ch>:FREQuency? MINimum",
+        "SOURce{ch}:FREQuency? MINimum",
         """This property queries the minimum frequency that can be set to the
         output waveform."""
     )
 
     phase = Instrument.control(
-        "SOURce<ch>:PHASe:ADJust?", "SOURce<ch>:PHASe:ADJust %s",
+        "SOURce{ch}:PHASe:ADJust?", "SOURce{ch}:PHASe:ADJust %s",
         """This property sets or queries the phase of the output waveform for
         the specified channel. The value is in degrees.""",
         validator=strict_range,
@@ -175,19 +172,19 @@ class ChannelAFG(ChannelBase):
     )
 
     phase_max = Instrument.measurement(
-        "SOURce<ch>:PHASe:ADJust? MAXimum",
+        "SOURce{ch}:PHASe:ADJust? MAXimum",
         """This property queries the maximum phase that can be set to the
         output waveform."""
     )
 
     phase_min = Instrument.measurement(
-        "SOURce<ch>:PHASe:ADJust? MINimum",
+        "SOURce{ch}:PHASe:ADJust? MINimum",
         """This property queries the minimum phase that can be set to the
         output waveform."""
     )
 
     voltage_unit = Instrument.control(
-        "OUTPut<ch>:VOLTage:UNIT?", "OUTPut<ch>:VOLTage:UNIT %s",
+        "OUTPut{ch}:VOLTage:UNIT?", "OUTPut{ch}:VOLTage:UNIT %s",
         """This property sets or queries the units of output amplitude, the
         possible choices are: VPP, VRMS, DBM. This command does not affect the
         offset, high level, or low level of output.""",
@@ -196,8 +193,8 @@ class ChannelAFG(ChannelBase):
     )
 
     voltage_low = Instrument.control(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:LOW?",
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:LOW %s",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:LOW?",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:LOW %s",
         """This property sets or queries the low level of the waveform. The
         low level could be limited by noise level to not exceed the maximum
         amplitude. If the carrier is Noise or DC level, this command and this
@@ -207,20 +204,20 @@ class ChannelAFG(ChannelBase):
     )
 
     voltage_low_max = Instrument.measurement(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:LOW? MAXimum",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:LOW? MAXimum",
         """This property queries the maximum low voltage level that can be set
         to the output waveform."""
     )
 
     voltage_low_min = Instrument.measurement(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:LOW? MINimum",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:LOW? MINimum",
         """This property queries the minimum low voltage level that can be set
         to the output waveform."""
     )
 
     voltage_high = Instrument.control(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:HIGH?",
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:HIGH %s",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:HIGH?",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:HIGH %s",
         """This property sets or queries the high level of the waveform. The
         high level could be limited by noise level to not exceed the maximum
         amplitude. If the carrier is Noise or DC level, this command and this
@@ -230,20 +227,20 @@ class ChannelAFG(ChannelBase):
     )
 
     voltage_high_max = Instrument.measurement(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:HIGH? MAXimum",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:HIGH? MAXimum",
         """This property queries the maximum high voltage level that can be set
         to the output waveform."""
     )
 
     voltage_high_min = Instrument.measurement(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:HIGH? MINimum",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:HIGH? MINimum",
         """This property queries the minimum high voltage level that can be set
         to the output waveform."""
     )
 
     voltage_amplitude = Instrument.control(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:AMPLitude?",
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:AMPLitude %s",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:AMPLitude?",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:AMPLitude %s",
         """This property sets or queries the output amplitude for the specified
         channel. The measurement unit of amplitude depends on the selection
         operated using the voltage_unit property. If the carrier is Noise the
@@ -256,22 +253,22 @@ class ChannelAFG(ChannelBase):
     )
 
     voltage_amplitude_max = Instrument.measurement(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:AMPLitude? MAXimum",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:AMPLitude? MAXimum",
         """This property queries the maximum amplitude voltage level that can
         be set to the output waveform.""",
         get_process=lambda value: float(value.replace("VPP", ""))
     )
 
     voltage_amplitude_min = Instrument.measurement(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:AMPLitude? MINimum",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:AMPLitude? MINimum",
         """This property queries the minimum amplitude voltage level that can
         be set to the output waveform.""",
         get_process=lambda value: float(value.replace("VPP", ""))
     )
 
     voltage_offset = Instrument.control(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:OFFSet?",
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:OFFSet %s",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:OFFSet?",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:OFFSet %s",
         """This property sets or queries the offset level for the specified
         channel. The offset range setting depends on the amplitude parameter.
         """,
@@ -280,20 +277,20 @@ class ChannelAFG(ChannelBase):
     )
 
     voltage_offset_max = Instrument.measurement(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:OFFSet? MAXimum",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:OFFSet? MAXimum",
         """This property queries the maximum offset voltage level that can be
         set to the output waveform."""
     )
 
     voltage_offset_min = Instrument.measurement(
-        "SOURce<ch>:VOLTage:LEVel:IMMediate:OFFSet? MINimum",
+        "SOURce{ch}:VOLTage:LEVel:IMMediate:OFFSet? MINimum",
         """This property queries the minimum offset voltage level that can be
         set to the output waveform."""
     )
 
     baseline_offset = Instrument.control(
-        "SOURce<ch>:VOLTage:BASELINE:OFFSET?",
-        "SOURce<ch>:VOLTage:BASELINE:OFFSET %s",
+        "SOURce{ch}:VOLTage:BASELINE:OFFSET?",
+        "SOURce{ch}:VOLTage:BASELINE:OFFSET %s",
         """This property sets or queries the offset level for the specified
         channel. The offset range setting depends on the amplitude parameter.
         """,
@@ -302,25 +299,17 @@ class ChannelAFG(ChannelBase):
     )
 
     baseline_offset_max = Instrument.measurement(
-        "SOURce<ch>:VOLTage:BASELINE:OFFSET? MAXimum",
+        "SOURce{ch}:VOLTage:BASELINE:OFFSET? MAXimum",
         """This property queries the maximum offset voltage level that can be
         set to the output waveform."""
     )
 
     baseline_offset_min = Instrument.measurement(
-        "SOURce<ch>:VOLTage:BASELINE:OFFSET? MINimum",
+        "SOURce{ch}:VOLTage:BASELINE:OFFSET? MINimum",
         """This property queries the minimum offset voltage level that can be
         set to the output waveform."""
     )
 
-    def __init__(self, instrument, channel_number):
-        super().__init__(instrument, channel_number)
-
-        self.calculate_voltage_range()
-        self.frequency_values = [self.frequency_min, self.frequency_max]
-
-        self.phase_values = [self.phase_min, self.phase_max]
-
     def calculate_voltage_range(self):
         self.voltage_low_values = [self.voltage_low_min, self.voltage_low_max]
 
@@ -341,14 +330,14 @@ class ChannelAWG(ChannelBase):
     """Implementation of a Active Technologies AWG-4000 channel in AWG mode."""
 
     # Default delay override
-    delay_get_command = "OUTPut<ch>:DELay?"
-    delay_set_command = "OUTPut<ch>:DELay %s"
-    delay_max_get_command = "OUTPut<ch>:DELay? MAXimum"
-    delay_min_get_command = "OUTPut<ch>:DELay? MINimum"
+    delay_get_command = "OUTPut{ch}:DELay?"
+    delay_set_command = "OUTPut{ch}:DELay %s"
+    delay_max_get_command = "OUTPut{ch}:DELay? MAXimum"
+    delay_min_get_command = "OUTPut{ch}:DELay? MINimum"
 
     scale = Instrument.control(
-        "OUTPut<ch>:SCALe?",
-        "OUTPut<ch>:SCALe %f",
+        "OUTPut{ch}:SCALe?",
+        "OUTPut{ch}:SCALe %f",
         """This property sets or returns the Amplitude Scale parameter of the
         analog channel “n”. This property can be modified at run-time to adjust
         the waveform amplitude while the instrument is running and it is
@@ -359,9 +348,6 @@ class ChannelAWG(ChannelBase):
         values=[0, 100]
     )
 
-    def __init__(self, instrument, channel_number):
-        super().__init__(instrument, channel_number)
-
 
 class AWG401x_base(Instrument):
     """AWG-401x base class"""
@@ -443,6 +429,7 @@ class AWG401x_AFG(AWG401x_base):
         print(wfg.check_errors())       # Get the error queue
 
     """
+    ch = Instrument.ChannelCreator(ChannelAFG, (1, 2))
 
     enabled = Instrument.control(
         "AFGControl:STATus?", "AFGControl:%s",
@@ -464,12 +451,12 @@ class AWG401x_AFG(AWG401x_base):
         elif model == "AWG4018":
             num_ch = 8
         else:
-            raise NotImplementedError(f"Instrument {model} non implemented in"
+            raise NotImplementedError(f"Instrument {model} not implemented in"
                                       "class AWG401x")
 
-        self.ch = {}
-        for i in range(1, num_ch+1):
-            self.ch[i] = ChannelAFG(self, i)
+        for i in range(3, num_ch + 1):
+            child = self.add_child(ChannelAFG, i, collection="ch")
+            child._protected = True
 
 
 class AWG401x_AWG(AWG401x_base):
@@ -502,6 +489,20 @@ class AWG401x_AWG(AWG401x_base):
 
     """
 
+    def __init__(self, adapter, **kwargs):
+        super().__init__(adapter, **kwargs)
+
+        for i in range(1, self.num_ch + 1):
+            self.add_child(ChannelAWG, i, collection="setting_ch")
+
+        self.entries = self.DummyEntriesElements(self, self.num_ch)
+        self.burst_count_values = [self.burst_count_min, self.burst_count_max]
+
+        self.sampling_rate_values = [self.sampling_rate_min,
+                                     self.sampling_rate_max]
+
+        self._waveforms = self.WaveformsLazyDict(self)
+
     num_ch = Instrument.measurement(
         "AWGControl:CONFigure:CNUMber?",
         """This property queries the number of analog channels.""",
@@ -671,21 +672,6 @@ class AWG401x_AWG(AWG401x_base):
         in the instrument system (Wave. List). It is possible to modify the
         values, delete them or create new waveforms""")
 
-    def __init__(self, adapter, **kwargs):
-        super().__init__(adapter, **kwargs)
-
-        self.setting_ch = {}
-        for i in range(1, self.num_ch+1):
-            self.setting_ch[i] = ChannelAWG(self, i)
-
-        self.entries = self.DummyEntriesElements(self, self.num_ch)
-        self.burst_count_values = [self.burst_count_min, self.burst_count_max]
-
-        self.sampling_rate_values = [self.sampling_rate_min,
-                                     self.sampling_rate_max]
-
-        self._waveforms = self.WaveformsLazyDict(self)
-
     def trigger(self):
         """Force a trigger event to occour."""
         self.write("TRIGger:SEQuence:IMMediate")
@@ -712,7 +698,6 @@ class AWG401x_AWG(AWG401x_base):
             'MMEM:DATA "' + file_name + '", 0, ',
             data.encode("ASCII"),
             datatype='s')
-
         # HACK: Send an unuseful command to ensure the last command was
         # executed because if it is more than 1024 bytes it doesn't work
         self.wait_last()
@@ -743,10 +728,10 @@ class AWG401x_AWG(AWG401x_base):
         FS_Element = namedtuple("FS_Element", "name type dimension")
 
         elements = []
-        for i in range(int(len(catalog)/3)):
-            elements.append(FS_Element(catalog[i*3+0],
-                                       catalog[i*3+1],
-                                       catalog[i*3+2]))
+        for i in range(int(len(catalog) / 3)):
+            elements.append(FS_Element(catalog[i * 3 + 0],
+                                       catalog[i * 3 + 1],
+                                       catalog[i * 3 + 2]))
 
         return elements
 
@@ -754,8 +739,8 @@ class AWG401x_AWG(AWG401x_base):
         """This class inherit from MutableMapping in order to create a custom
         dict to lazy load, modify, delete and create instrument waveform."""
 
-        def __init__(self, instrument):
-            self.instrument = instrument
+        def __init__(self, parent):
+            self.parent = parent
             self.reset()
 
         def __getitem__(self, key):
@@ -776,32 +761,32 @@ class AWG401x_AWG(AWG401x_base):
             class VoltageOutOfRangeError(Exception):
                 pass
 
-            if max(value) > self.instrument.entries[1].ch[1].voltage_high_max:
+            if max(value) > self.parent.entries[1].ch[1].voltage_high_max:
                 raise VoltageOutOfRangeError(
                     f"{max(value)}V is higher than maximum possible voltage, "
                     f"which is "
                     f"{self.instrument.entries[1].ch[1].voltage_high_max}V")
-            if min(value) < self.instrument.entries[1].ch[1].voltage_low_min:
+            if min(value) < self.parent.entries[1].ch[1].voltage_low_min:
                 raise VoltageOutOfRangeError(
                     f"{min(value)}V is lower than minimum possible voltage, "
                     f"which is "
                     f"{self.instrument.entries[1].ch[1].voltage_low_min}V")
 
-            self.instrument.save_file(f"{key}.txt",
-                                      "\n".join(map(str, value)),
-                                      override_existing=True)
+            self.parent.save_file(f"{key}.txt",
+                                  "\n".join(map(str, value)),
+                                  override_existing=True)
 
             try:
                 del self[key]
             except KeyError:
                 pass
 
-            self.instrument.write(f'WLISt:WAVeform:IMPort "{key}",'
-                                  f'"{key}.txt",ANAlog')
+            self.parent.write(f'WLISt:WAVeform:IMPort "{key}",'
+                              f'"{key}.txt",ANAlog')
 
-            self.instrument.wait_last()
+            self.parent.wait_last()
 
-            self.instrument.remove_file(f"{key}.txt")
+            self.parent.remove_file(f"{key}.txt")
 
             self._data[key] = None
             return
@@ -810,7 +795,7 @@ class AWG401x_AWG(AWG401x_base):
             """When removing an element this method removes also the
             corresponding waveform in the instrument"""
             del self._data[key]
-            self.instrument.write(f'WLISt:WAVeform:DELete "{key}"')
+            self.parent.write(f'WLISt:WAVeform:DELete "{key}"')
             return
 
         def __iter__(self):
@@ -830,13 +815,13 @@ class AWG401x_AWG(AWG401x_base):
 
         def reset(self):
             """Reset the class reloading the waveforms from instrument"""
-            waveforms_name = self.instrument.values("WLISt:LIST?")
+            waveforms_name = self.parent.values("WLISt:LIST?")
             self._data = {v: None for v in waveforms_name}
 
         def _get_waveform(self, waveform_name):
             """Get the waveform point of a specified waveform"""
 
-            bin_value = self.instrument.adapter.connection.query_binary_values(
+            bin_value = self.parent.adapter.connection.query_binary_values(
                 'WLISt:WAVeform:DATA? "' + waveform_name + '"',
                 header_fmt='ieee',
                 datatype='h')
@@ -847,29 +832,44 @@ class AWG401x_AWG(AWG401x_base):
         """Dummy List Class to list every sequencer entry. The content is
         loaded in real-time."""
 
-        def resize(self, new_size):
-            self.instrument.write(f"SEQuence:LENGth {new_size}")
-
-        def __init__(self, instrument, number_of_channel):
-            self.instrument = instrument
+        def __init__(self, parent, number_of_channel):
+            self.parent = parent
             self.num_ch = number_of_channel
 
+        def resize(self, new_size):
+            self.parent.write(f"SEQuence:LENGth {new_size}")
+
         def __getitem__(self, key):
             if key <= 0:
                 raise IndexError("Entry numeration start from 1")
-            if key > int(self.instrument.values("SEQuence:LENGth?")[0]):
+            if key > int(self.parent.values("SEQuence:LENGth?")[0]):
                 raise IndexError("Index out of range")
-            return SequenceEntry(self.instrument, self.num_ch, key)
+            return (self.parent, self.num_ch, key)
 
         def __len__(self):
-            return int(self.instrument.values("SEQuence:LENGth?")[0])
+            return int(self.parent.values("SEQuence:LENGth?")[0])
 
 
-class SequenceEntry(Instrument):
+class SequenceEntry(Channel):
     """Implementation of sequencer entry."""
+
+    def __init__(self, parent, number_of_channels, sequence_number):
+        super().__init__(parent, sequence_number)
+        self.number_of_channels = number_of_channels
+
+        self.length_values = [self.length_min, self.length_max]
+        self.loop_count_values = [self.loop_count_min, self.loop_count_max]
+
+        for i in range(1, self.number_of_channels + 1):
+            self.add_child(self.AnalogChannel, i, collection="ch",
+                           sequence_number=sequence_number)
+
+    def insert_id(self, command):
+        return command.format(ent=self.id)
+
     length = Instrument.control(
-        "SEQuence:ELEM<ent>:LENGth?",
-        "SEQuence:ELEM<ent>:LENGth %s",
+        "SEQuence:ELEM{ent}:LENGth?",
+        "SEQuence:ELEM{ent}:LENGth %s",
         """This property sets or returns the number of samples of the entry.
         """,
         validator=strict_range,
@@ -877,20 +877,20 @@ class SequenceEntry(Instrument):
     )
 
     length_max = Instrument.measurement(
-        "SEQuence:ELEM<ent>:LENGth? MAXimum",
+        "SEQuence:ELEM{ent}:LENGth? MAXimum",
         """This property queries the maximum entry samples length.""",
         get_process=lambda v: int(v)
     )
 
     length_min = Instrument.measurement(
-        "SEQuence:ELEM<ent>:LENGth? MINimum",
+        "SEQuence:ELEM{ent}:LENGth? MINimum",
         """This property queries the minimum entry samples length.""",
         get_process=lambda v: int(v)
     )
 
     loop_count = Instrument.control(
-        "SEQuence:ELEM<ent>:LOOP:COUNt?",
-        "SEQuence:ELEM<ent>:LOOP:COUNt %s",
+        "SEQuence:ELEM{ent}:LOOP:COUNt?",
+        "SEQuence:ELEM{ent}:LOOP:COUNt %s",
         """This property sets or returns the number of waveform repetitions for
         the entry.
         """,
@@ -899,46 +899,35 @@ class SequenceEntry(Instrument):
     )
 
     loop_count_max = Instrument.measurement(
-        "SEQuence:ELEM<ent>:LOOP:COUNt? MAXimum",
+        "SEQuence:ELEM{ent}:LOOP:COUNt? MAXimum",
         """This property queries the maximum number of waveform repetitions for
         the entry.""",
         get_process=lambda v: int(v)
     )
 
     loop_count_min = Instrument.measurement(
-        "SEQuence:ELEM<ent>:LOOP:COUNt? MINimum",
+        "SEQuence:ELEM{ent}:LOOP:COUNt? MINimum",
         """This property queries the minimum number of waveform repetitions for
         the entry.""",
         get_process=lambda v: int(v)
     )
 
-    def __init__(self, instrument, number_of_channels, sequence_number):
-        self.instrument = instrument
-        self.number_of_channels = number_of_channels
-        self.seq_num = sequence_number
-
-        self._special_names = self._setup_special_names()
-
-        self.length_values = [self.length_min, self.length_max]
-        self.loop_count_values = [self.loop_count_min, self.loop_count_max]
+    class AnalogChannel(Channel):
+        """Implementation of an analog channel for a single sequencer entry."""
 
-        self.ch = {}
-        for i in range(1, self.number_of_channels+1):
-            self.ch[i] = self.AnalogChannel(self.instrument, i, self.seq_num)
+        def __init__(self, parent, id, sequence_number):
+            super().__init__(parent, id)
+            self.seq_num = sequence_number
 
-    def values(self, command, **kwargs):
-        return self.instrument.values(
-            command.replace("<ent>", str(self.seq_num)),
-            **kwargs)
+            self.waveform_values = list(self.parent.parent.waveforms.keys())
+            self.calculate_voltage_range()
 
-    def write(self, command):
-        self.instrument.write(command.replace("<ent>", str(self.seq_num)))
+        def insert_id(self, command):
+            return command.format(ent=self.seq_num, ch=self.id)
 
-    class AnalogChannel(Instrument):
-        """Implementation of an analog channel for a single sequencer entry."""
         voltage_amplitude = Instrument.control(
-            "SEQuence:ELEM<ent>:AMPlitude<ch>?",
-            "SEQuence:ELEM<ent>:AMPlitude<ch> %s",
+            "SEQuence:ELEM{ent}:AMPlitude{ch}?",
+            "SEQuence:ELEM{ent}:AMPlitude{ch} %s",
             """This property sets or returns the voltage peak-to-peak
             amplitude.""",
             validator=strict_range,
@@ -946,40 +935,40 @@ class SequenceEntry(Instrument):
         )
 
         voltage_amplitude_max = Instrument.measurement(
-            "SEQuence:ELEM<ent>:AMPlitude<ch>? MAXimum",
+            "SEQuence:ELEM{ent}:AMPlitude{ch}? MAXimum",
             """This property queries the maximum amplitude voltage level that
             can be set."""
         )
 
         voltage_amplitude_min = Instrument.measurement(
-            "SEQuence:ELEM<ent>:AMPlitude<ch>? MINimum",
+            "SEQuence:ELEM{ent}:AMPlitude{ch}? MINimum",
             """This property queries the minimum amplitude voltage level that
             can be set."""
         )
 
         voltage_offset = Instrument.control(
-            "SEQuence:ELEM<ent>:OFFset<ch>?",
-            "SEQuence:ELEM<ent>:OFFset<ch> %s",
+            "SEQuence:ELEM{ent}:OFFset{ch}?",
+            "SEQuence:ELEM{ent}:OFFset{ch} %s",
             """This property sets or returns the voltage offset.""",
             validator=strict_range,
             dynamic=True
         )
 
         voltage_offset_max = Instrument.measurement(
-            "SEQuence:ELEM<ent>:OFFset<ch>? MAXimum",
+            "SEQuence:ELEM{ent}:OFFset{ch}? MAXimum",
             """This property queries the maximum voltage offset that can be
             set."""
         )
 
         voltage_offset_min = Instrument.measurement(
-            "SEQuence:ELEM<ent>:OFFset<ch>? MINimum",
+            "SEQuence:ELEM{ent}:OFFset{ch}? MINimum",
             """This property queries the minimum voltage offset that can be
             set."""
         )
 
         voltage_high = Instrument.control(
-            "SEQuence:ELEM<ent>:VOLTage:HIGH<ch>?",
-            "SEQuence:ELEM<ent>:VOLTage:HIGH<ch> %s",
+            "SEQuence:ELEM{ent}:VOLTage:HIGH{ch}?",
+            "SEQuence:ELEM{ent}:VOLTage:HIGH{ch} %s",
             """This property sets or returns the high voltage level of the
             waveform.""",
             validator=strict_range,
@@ -987,20 +976,20 @@ class SequenceEntry(Instrument):
         )
 
         voltage_high_max = Instrument.measurement(
-            "SEQuence:ELEM<ent>:VOLTage:HIGH<ch>? MAXimum",
+            "SEQuence:ELEM{ent}:VOLTage:HIGH{ch}? MAXimum",
             """This property queries the maximum high voltage level of the
             waveform that can be set to the output waveform."""
         )
 
         voltage_high_min = Instrument.measurement(
-            "SEQuence:ELEM<ent>:VOLTage:HIGH<ch>? MINimum",
+            "SEQuence:ELEM{ent}:VOLTage:HIGH{ch}? MINimum",
             """This property queries the minimum high voltage level of the
             waveform that can be set to the output waveform."""
         )
 
         voltage_low = Instrument.control(
-            "SEQuence:ELEM<ent>:VOLTage:LOW<ch>?",
-            "SEQuence:ELEM<ent>:VOLTage:LOW<ch> %s",
+            "SEQuence:ELEM{ent}:VOLTage:LOW{ch}?",
+            "SEQuence:ELEM{ent}:VOLTage:LOW{ch} %s",
             """This property sets or returns the low voltage level of the
             waveform.""",
             validator=strict_range,
@@ -1008,20 +997,20 @@ class SequenceEntry(Instrument):
         )
 
         voltage_low_max = Instrument.measurement(
-            "SEQuence:ELEM<ent>:VOLTage:LOW<ch>? MAXimum",
+            "SEQuence:ELEM{ent}:VOLTage:LOW{ch}? MAXimum",
             """This property queries the maximum low voltage level of the
             waveform that can be set to the output waveform."""
         )
 
         voltage_low_min = Instrument.measurement(
-            "SEQuence:ELEM<ent>:VOLTage:LOW<ch>? MINimum",
+            "SEQuence:ELEM{ent}:VOLTage:LOW{ch}? MINimum",
             """This property queries the minimum low voltage level of the
             waveform that can be set to the output waveform."""
         )
 
         waveform = Instrument.control(
-            "SEQuence:ELEM<ent>:WAVeform<ch>?",
-            "SEQuence:ELEM<ent>:WAVeform<ch> %s",
+            "SEQuence:ELEM{ent}:WAVeform{ch}?",
+            "SEQuence:ELEM{ent}:WAVeform{ch} %s",
             """This property sets or returns the waveform. It’s possible select
             a waveform only from those in the waveform list. In waveform list
             are already present 10 predefined waveform: Sine, Ramp, Square,
@@ -1032,26 +1021,6 @@ class SequenceEntry(Instrument):
             dynamic=True
         )
 
-        def __init__(self, instrument, channel_number, sequence_number):
-            self.instrument = instrument
-            self.num_ch = channel_number
-            self.seq_num = sequence_number
-
-            self._special_names = self._setup_special_names()
-
-            self.waveform_values = list(self.instrument.waveforms.keys())
-            self.calculate_voltage_range()
-
-        def values(self, command, **kwargs):
-            command = command.replace("<ent>", str(self.seq_num))
-            command = command.replace("<ch>", str(self.num_ch))
-            return self.instrument.values(command, **kwargs)
-
-        def write(self, command):
-            command = command.replace("<ent>", str(self.seq_num))
-            command = command.replace("<ch>", str(self.num_ch))
-            self.instrument.write(command)
-
         def calculate_voltage_range(self):
             self.voltage_amplitude_values = [self.voltage_amplitude_min,
                                              self.voltage_amplitude_max]
diff --git a/pymeasure/instruments/channel.py b/pymeasure/instruments/channel.py
new file mode 100644
index 00000000..e455420e
--- /dev/null
+++ b/pymeasure/instruments/channel.py
@@ -0,0 +1,118 @@
+#
+# This file is part of the PyMeasure package.
+#
+# Copyright (c) 2013-2022 PyMeasure Developers
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+
+import logging
+
+from .common_base import CommonBase
+
+log = logging.getLogger(__name__)
+log.addHandler(logging.NullHandler())
+
+
+class Channel(CommonBase):
+    """The base class for channel definitions.
+
+    This class supports dynamic properties like :class:`Instrument`,
+    but requires an :class:`Instrument` instance as a parent for communication.
+
+    :meth:`insert_id` inserts the channel id into the command string sent to the instrument.
+    The default implementation replaces the Channel's `placeholder` (default "ch")
+    with the channel id in all command strings (e.g. "CHANnel{ch}:foo").
+
+    :param parent: The instrument (an instance of :class:`~pymeasure.instruments.Instrument`)
+        to which the channel belongs.
+    :param id: Identifier of the channel, as it is used for the communication.
+    """
+
+    placeholder = "ch"
+
+    def __init__(self, parent, id):
+        self.parent = parent
+        self.id = id
+        super().__init__()
+
+    def insert_id(self, command):
+        """Insert the channel id in a command replacing `placeholder`.
+
+        Subclass this method if you want to do something else,
+        like always prepending the channel id.
+        """
+        return command.format_map({self.placeholder: self.id})
+
+    # Calls to the instrument
+    def write(self, command, **kwargs):
+        """Write a string command to the instrument appending `write_termination`.
+
+        :param command: command string to be sent to the instrument.
+            '{ch}' is replaced by the channel id.
+        :param kwargs: Keyword arguments for the adapter.
+        """
+        self.parent.write(self.insert_id(command), **kwargs)
+
+    def write_bytes(self, content, **kwargs):
+        """Write the bytes `content` to the instrument."""
+        self.parent.write_bytes(content, **kwargs)
+
+    def read(self, **kwargs):
+        """Read up to (excluding) `read_termination` or the whole read buffer."""
+        return self.parent.read(**kwargs)
+
+    def read_bytes(self, count, **kwargs):
+        """Read a certain number of bytes from the instrument.
+
+        :param int count: Number of bytes to read. A value of -1 indicates to
+            read the whole read buffer.
+        :param kwargs: Keyword arguments for the adapter.
+        :returns bytes: Bytes response of the instrument (including termination).
+        """
+        return self.parent.read_bytes(count, **kwargs)
+
+    def write_binary_values(self, command, values, *args, **kwargs):
+        """Write binary values to the instrument.
+
+        :param command: Command to send.
+        :param values: The values to transmit.
+        :param \\*args, \\**kwargs: Further arguments to hand to the Adapter.
+        """
+        self.parent.write_binary_values(self.insert_id(command),
+                                        values, *args, **kwargs)
+
+    def read_binary_values(self, **kwargs):
+        """Read binary values from the instrument."""
+        return self.parent.read_binary_values(**kwargs)
+
+    def check_errors(self):
+        """Read all errors from the instrument.
+
+        :return: list of error entries
+        """
+        return self.parent.check_errors()
+
+    # Communication functions
+    def wait_for(self, query_delay=0):
+        """Wait for some time. Used by 'ask' to wait before reading.
+
+        :param query_delay: Delay between writing and reading in seconds.
+        """
+        self.parent.wait_for(query_delay)
diff --git a/pymeasure/instruments/common_base.py b/pymeasure/instruments/common_base.py
new file mode 100644
index 00000000..d3f85624
--- /dev/null
+++ b/pymeasure/instruments/common_base.py
@@ -0,0 +1,548 @@
+#
+# This file is part of the PyMeasure package.
+#
+# Copyright (c) 2013-2022 PyMeasure Developers
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+
+import logging
+
+log = logging.getLogger(__name__)
+log.addHandler(logging.NullHandler())
+
+
+class DynamicProperty(property):
+    """ Class that allows managing python property behaviour in a "dynamic" fashion
+
+    The class allows passing, in addition to regular property parameters, a list of
+    runtime configurable parameters.
+    The effect is that the behaviour of fget/fset not only depends on the obj parameter, but
+    also on a set of keyword parameters with a default value.
+    These extra parameters are read from instance, if available, or left with the default value.
+    Dynamic behaviour is achieved by changing class or instance variables with special names
+    defined as `<prefix> + <property name> + <param name>`.
+
+    Code has been based on Python equivalent implementation of properties provided in the
+    python documentation `here <https://docs.python.org/3/howto/descriptor.html#properties>`_.
+
+    :param fget: class property fget parameter whose signature is expanded with a
+                 set of keyword arguments as in fget_params_list
+    :param fset: class property fget parameter whose signature is expanded with a
+                 set of keyword arguments as in fset_params_list
+    :param fdel: class property fdel parameter
+    :param doc: class property doc parameter
+    :param fget_params_list: List of parameter names that are dynamically configurable
+    :param fset_params_list: List of parameter names that are dynamically configurable
+    :param prefix: String to be prefixed to get dynamically configurable
+                   parameters.
+    """
+
+    def __init__(self, fget=None, fset=None, fdel=None, doc=None, fget_params_list=None,
+                 fset_params_list=None, prefix=""):
+        super().__init__(fget, fset, fdel, doc)
+        self.fget_params_list = () if fget_params_list is None else fget_params_list
+        self.fset_params_list = () if fset_params_list is None else fset_params_list
+        self.name = ""
+        self.prefix = prefix
+
+    def __get__(self, obj, objtype=None):
+        if obj is None:
+            # Property return itself when invoked from a class
+            return self
+        if self.fget is None:
+            raise AttributeError(f"Unreadable attribute {self.name}")
+
+        kwargs = {}
+        for attr in self.fget_params_list:
+            attr_instance_name = self.prefix + "_".join([self.name, attr])
+            if hasattr(obj, attr_instance_name):
+                kwargs[attr] = getattr(obj, attr_instance_name)
+        return self.fget(obj, **kwargs)
+
+    def __set__(self, obj, value):
+        if self.fset is None:
+            raise AttributeError(f"Can't set attribute {self.name}")
+        kwargs = {}
+        for attr in self.fset_params_list:
+            attr_instance_name = self.prefix + "_".join([self.name, attr])
+            if hasattr(obj, attr_instance_name):
+                kwargs[attr] = getattr(obj, attr_instance_name)
+        self.fset(obj, value, **kwargs)
+
+    def __set_name__(self, owner, name):
+        self.name = name
+
+
+class CommonBase:
+    """Base class for instruments and channels.
+
+    This class contains everything needed for pymeasure's property creator
+    :meth:`control` and its derivatives :meth:`measurement` and :meth:`setting`.
+    """
+
+    # Variable holding the list of DynamicProperty parameters that are configurable
+    # by users
+    _fget_params_list = ('get_command',
+                         'values',
+                         'map_values',
+                         'get_process',
+                         'command_process',
+                         'check_get_errors')
+
+    _fset_params_list = ('set_command',
+                         'validator',
+                         'values',
+                         'map_values',
+                         'set_process',
+                         'command_process',
+                         'check_set_errors')
+
+    # Prefix used to store reserved variables
+    __reserved_prefix = "___"
+
+    def __init__(self):
+        self._special_names = self._setup_special_names()
+        # Add children from ChildDescriptors.
+        for item, value in self.__class__.__dict__.items():
+            if isinstance(value, self.ChannelCreator):
+                for cls, id in value.pairs:
+                    child = self.add_child(cls, id, collection=item, **value.kwargs)
+                    child._protected = True
+
+    class ChannelCreator:
+        """Add channels to the parent class.
+
+        The children will be added to the parent instance at instantiation with
+        :func:`CommonBase.add_child`. The variable name (e.g. :code:`channels`) will be
+        used as the `collection` of the children. You may define the attribute
+        prefix. If there are no other pressing reasons, use :code:`channels` as variable
+        and leave the prefix at the default :code:`"ch_"`.
+
+        .. code::
+
+            class SomeInstrument(Instrument):
+                # Three channels of the same type: 'ch_A', 'ch_B', 'ch_C' in 'channels'
+                channels = Instrument.ChannelCreator(ChildClass, ["A", "B", "C"])
+                # Two functions of different types: 'fn_power', 'fn_voltage' in 'functions'
+                functions = Instrument.ChannelCreator((PowerChannel, VoltageChannel),
+                                                ["power", "voltage"], prefix="fn_")
+                # A channel without a prefixed attribute name, simply: 'motor'
+                motor = Instrument.ChannelCreator(MotorControl, prefix=None)
+
+        :param cls: Class for all children or tuple/list of classes, one for each child.
+        :param id: Single value or tuple/list of ids of the children.
+        :param prefix: Collection prefix for the attributes, e.g. `"ch_"`
+            creates attribute `self.ch_A`. If prefix evaluates False,
+            the child will be added directly under the variable name.
+        :param \\**kwargs: Keyword arguments for all children.
+        """
+
+        def __init__(self, cls, id=None, prefix="ch_", **kwargs):
+            try:
+                valid_class = issubclass(cls, CommonBase)
+            except TypeError:
+                valid_class = False
+            if isinstance(id, (list, tuple)) and isinstance(cls, (list, tuple)):
+                assert (len(id) == len(cls)), "Lengths of cls and id do not match."
+                self.pairs = list(zip(cls, id))
+            elif isinstance(id, (list, tuple)) and valid_class:
+                self.pairs = list(zip((cls,) * len(id), id))
+            elif (isinstance(id, (str, int)) or id is None) and valid_class:
+                self.pairs = ((cls, id),)
+            else:
+                raise ValueError("Invalid definition of classes '{cls}' and ids '{id}'.")
+            kwargs.setdefault("prefix", prefix)
+            self.kwargs = kwargs
+
+    def _setup_special_names(self):
+        """ Return list of class/instance special names.
+
+        Compute the list of special names based on the list of
+        class attributes that are a DynamicProperty. Check also for class variables
+        with special name and copy them at instance level
+        Internal method, not intended to be accessed at user level."""
+        special_names = []
+        dynamic_params = tuple(set(self._fget_params_list + self._fset_params_list))
+        # Check whether class variables of DynamicProperty type are present
+        for obj in (self,) + self.__class__.__mro__:
+            for attr_name, attr in obj.__dict__.items():
+                if isinstance(attr, DynamicProperty):
+                    special_names += [attr_name + "_" + key for key in dynamic_params]
+        # Check if special variables are defined at class level
+        for obj in (self,) + self.__class__.__mro__:
+            for attr in obj.__dict__:
+                if attr in special_names:
+                    # Copy class special variable at instance level, prefixing reserved_prefix
+                    setattr(self, self.__reserved_prefix + attr, obj.__dict__[attr])
+        return special_names
+
+    def __setattr__(self, name, value):
+        """ Add reserved_prefix in front of special variables."""
+        if hasattr(self, '_special_names'):
+            if name in self._special_names:
+                name = self.__reserved_prefix + name
+        super().__setattr__(name, value)
+
+    def __getattribute__(self, name):
+        """ Prevent read access to variables with special names used to
+        support dynamic property behaviour."""
+        if name in ('_special_names', '__dict__'):
+            return super().__getattribute__(name)
+        if hasattr(self, '_special_names'):
+            if name in self._special_names:
+                raise AttributeError(
+                    f"{name} is a reserved variable name and it cannot be read")
+        return super().__getattribute__(name)
+
+    # Channel management
+    def add_child(self, cls, id=None, collection="channels", prefix="ch_", **kwargs):
+        """Add a child to this instance and return its index in the children list.
+
+        The newly created child may be accessed either by the id in the
+        children dictionary or by the created attribute.
+        The fifth channel of `instrument` with id "F" has two access options:
+        :code:`instrument.channels["F"] == instrument.ch_F`
+
+        .. note::
+
+            Do not change the default `collection` or `prefix` parameter, unless
+            you have to distinguish several collections of different children,
+            e.g. different channel types (analog and digital).
+
+        :param cls: Class of the channel.
+        :param id: Child id how it is used in communication, e.g. `"A"`.
+        :param collection: Name of the collection of children, used for the dictionary.
+        :param prefix: Collection prefix for the attributes, e.g. `"ch_"`
+            creates attribute `self.ch_A`. If prefix evaluates False,
+            the child will be added directly under the collection name.
+        :param \\**kwargs: Keyword arguments for the channel creator.
+        :returns: Instance of the created child.
+        """
+        child = cls(self, id, **kwargs)
+        collection_data = getattr(self, collection, {})
+        if isinstance(collection_data, CommonBase.ChannelCreator):
+            collection_data = {}
+        if prefix:
+            if not collection_data:
+                # Add a grouplist to the parent.
+                setattr(self, collection, collection_data)
+            collection_data[id] = child
+            child._collection = collection
+            setattr(self, f"{prefix}{id}", child)
+            child._name = f"{prefix}{id}"
+        else:
+            if collection_data:
+                raise ValueError(f"An attribute '{collection}' already exists.")
+            setattr(self, collection, child)
+            child._name = collection
+        return child
+
+    def remove_child(self, child):
+        """Remove the child from the instrument and the corresponding collection.
+
+        :param child: Instance of the child to delete.
+        """
+        if hasattr(child, "_protected"):
+            raise TypeError("You cannot remove channels defined at class level.")
+        if hasattr(child, "_collection"):
+            collection = getattr(self, child._collection)
+            del collection[child.id]
+        delattr(self, child._name)
+
+    # Communication functions
+    def wait_for(self, query_delay=0):
+        """Wait for some time. Used by 'ask' to wait before reading.
+
+        Implement in subclass!
+
+        :param query_delay: Delay between writing and reading in seconds.
+        """
+        raise NotImplementedError("Implement in subclass!")
+
+    def ask(self, command, query_delay=0):
+        """Write a command to the instrument and return the read response.
+
+        :param command: Command string to be sent to the instrument.
+        :param query_delay: Delay between writing and reading in seconds.
+        :returns: String returned by the device without read_termination.
+        """
+        self.write(command)
+        self.wait_for(query_delay)
+        return self.read()
+
+    def values(self, command, separator=',', cast=float, preprocess_reply=None):
+        """Write a command to the instrument and return a list of formatted
+        values from the result.
+
+        :param command: SCPI command to be sent to the instrument
+        :param separator: A separator character to split the string into a list
+        :param cast: A type to cast the result
+        :param preprocess_reply: optional callable used to preprocess values
+            received from the instrument. The callable returns the processed
+            string.
+        :returns: A list of the desired type, or strings where the casting fails
+        """
+        results = str(self.ask(command)).strip()
+        if callable(preprocess_reply):
+            results = preprocess_reply(results)
+        results = results.split(separator)
+        for i, result in enumerate(results):
+            try:
+                if cast == bool:
+                    # Need to cast to float first since results are usually
+                    # strings and bool of a non-empty string is always True
+                    results[i] = bool(float(result))
+                else:
+                    results[i] = cast(result)
+            except Exception:
+                pass  # Keep as string
+        return results
+
+    def binary_values(self, command, query_delay=0, **kwargs):
+        """ Write a command to the instrument and return a numpy array of the binary data.
+
+        :param command: Command to be sent to the instrument.
+        :param query_delay: Delay between writing and reading in seconds.
+        :param kwargs: Arguments for :meth:`~pymeasure.Adapter.read_binary_values`.
+        :returns: NumPy array of values.
+        """
+        self.write(command)
+        self.wait_for(query_delay)
+        return self.read_binary_values(**kwargs)
+
+    # Property creators
+    @staticmethod
+    def control(  # noqa: C901 accept that this is a complex method
+        get_command,
+        set_command,
+        docs,
+        validator=lambda v, vs: v,
+        values=(),
+        map_values=False,
+        get_process=lambda v: v,
+        set_process=lambda v: v,
+        command_process=lambda c: c,
+        check_set_errors=False,
+        check_get_errors=False,
+        dynamic=False,
+        **kwargs
+    ):
+        """Return a property for the class based on the supplied
+        commands. This property may be set and read from the
+        instrument. See also :meth:`measurement` and :meth:`setting`.
+
+        :param get_command: A string command that asks for the value, set to `None`
+            if get is not supported (see also :meth:`setting`).
+        :param set_command: A string command that writes the value, set to `None`
+            if set is not supported (see also :meth:`measurement`).
+        :param docs: A docstring that will be included in the documentation
+        :param validator: A function that takes both a value and a group of valid values
+            and returns a valid value, while it otherwise raises an exception
+        :param values: A list, tuple, range, or dictionary of valid values, that can be used
+            as to map values if :code:`map_values` is True.
+        :param map_values: A boolean flag that determines if the values should be
+            interpreted as a map
+        :param get_process: A function that take a value and allows processing
+            before value mapping, returning the processed value
+        :param set_process: A function that takes a value and allows processing
+            before value mapping, returning the processed value
+        :param command_process: A function that takes a command and allows processing
+            before executing the command
+        :param check_set_errors: Toggles checking errors after setting
+        :param check_get_errors: Toggles checking errors after getting
+        :param dynamic: Specify whether the property parameters are meant to be changed in
+            instances or subclasses.
+
+        Example of usage of dynamic parameter is as follows:
+
+        .. code-block:: python
+
+            class GenericInstrument(Instrument):
+                center_frequency = Instrument.control(
+                    ":SENS:FREQ:CENT?;", ":SENS:FREQ:CENT %e GHz;",
+                    " A floating point property that represents the frequency ... ",
+                    validator=strict_range,
+                    # Redefine this in subclasses to reflect actual instrument value:
+                    values=(1, 20),
+                    dynamic=True  # enable changing property parameters on-the-fly
+                )
+
+            class SpecificInstrument(GenericInstrument):
+                # Identical to GenericInstrument, except for frequency range
+                # Override the "values" parameter of the "center_frequency" property
+                center_frequency_values = (1, 10) # Redefined at subclass level
+
+            instrument = SpecificInstrument()
+            instrument.center_frequency_values = (1, 6e9) # Redefined at instance level
+
+        .. warning:: Unexpected side effects when using dynamic properties
+
+        Users must pay attention when using dynamic properties, since definition of class and/or
+        instance attributes matching specific patterns could have unwanted side effect.
+        The attribute name pattern `property_param`, where `property` is the name of the dynamic
+        property (e.g. `center_frequency` in the example) and `param` is any of this method
+        parameters name except `dynamic` and `docs` (e.g. `values` in the example) has to be
+        considered reserved for dynamic property control.
+        """
+
+        def fget(self,
+                 get_command=get_command,
+                 values=values,
+                 map_values=map_values,
+                 get_process=get_process,
+                 command_process=command_process,
+                 check_get_errors=check_get_errors,
+                 ):
+            if get_command is None:
+                raise LookupError("Property can not be read.")
+            vals = self.values(command_process(get_command), **kwargs)
+            if check_get_errors:
+                self.check_errors()
+            if len(vals) == 1:
+                value = get_process(vals[0])
+                if not map_values:
+                    return value
+                elif isinstance(values, (list, tuple, range)):
+                    return values[int(value)]
+                elif isinstance(values, dict):
+                    for k, v in values.items():
+                        if v == value:
+                            return k
+                    raise KeyError(f"Value {value} not found in mapped values")
+                else:
+                    raise ValueError(
+                        'Values of type `{}` are not allowed '
+                        'for Instrument.control'.format(type(values))
+                    )
+            else:
+                vals = get_process(vals)
+                return vals
+
+        def fset(self,
+                 value,
+                 set_command=set_command,
+                 validator=validator,
+                 values=values,
+                 map_values=map_values,
+                 set_process=set_process,
+                 command_process=command_process,
+                 check_set_errors=check_set_errors,
+                 ):
+
+            if set_command is None:
+                raise LookupError("Property can not be set.")
+
+            value = set_process(validator(value, values))
+            if not map_values:
+                pass
+            elif isinstance(values, (list, tuple, range)):
+                value = values.index(value)
+            elif isinstance(values, dict):
+                value = values[value]
+            else:
+                raise ValueError(
+                    'Values of type `{}` are not allowed '
+                    'for CommonBase.control'.format(type(values))
+                )
+            self.write(command_process(set_command) % value)
+            if check_set_errors:
+                self.check_errors()
+
+        # Add the specified document string to the getter
+        fget.__doc__ = docs
+
+        if dynamic:
+            fget.__doc__ += "(dynamic)"
+            return DynamicProperty(fget=fget, fset=fset,
+                                   fget_params_list=CommonBase._fget_params_list,
+                                   fset_params_list=CommonBase._fset_params_list,
+                                   prefix=CommonBase.__reserved_prefix)
+        else:
+            return property(fget, fset)
+
+    @staticmethod
+    def measurement(get_command, docs, values=(), map_values=None,
+                    get_process=lambda v: v, command_process=lambda c: c,
+                    check_get_errors=False, dynamic=False, **kwargs):
+        """ Return a property for the class based on the supplied
+        commands. This is a measurement quantity that may only be
+        read from the instrument, not set.
+
+        :param get_command: A string command that asks for the value
+        :param docs: A docstring that will be included in the documentation
+        :param values: A list, tuple, range, or dictionary of valid values, that can be used
+            as to map values if :code:`map_values` is True.
+        :param map_values: A boolean flag that determines if the values should be
+            interpreted as a map
+        :param get_process: A function that take a value and allows processing
+            before value mapping, returning the processed value
+        :param command_process: A function that take a command and allows processing
+            before executing the command, for getting
+        :param check_get_errors: Toggles checking errors after getting
+        :param dynamic: Specify whether the property parameters are meant to be changed in
+            instances or subclasses. See :meth:`control` for an usage example.
+        """
+
+        return CommonBase.control(get_command=get_command,
+                                  set_command=None,
+                                  docs=docs,
+                                  values=values,
+                                  map_values=map_values,
+                                  get_process=get_process,
+                                  command_process=command_process,
+                                  check_get_errors=check_get_errors,
+                                  dynamic=dynamic,
+                                  **kwargs)
+
+    @staticmethod
+    def setting(set_command, docs,
+                validator=lambda x, y: x, values=(), map_values=False,
+                set_process=lambda v: v,
+                check_set_errors=False, dynamic=False,
+                **kwargs):
+        """Return a property for the class based on the supplied
+        commands. This property may be set, but raises an exception
+        when being read from the instrument.
+
+        :param set_command: A string command that writes the value
+        :param docs: A docstring that will be included in the documentation
+        :param validator: A function that takes both a value and a group of valid values
+            and returns a valid value, while it otherwise raises an exception
+        :param values: A list, tuple, range, or dictionary of valid values, that can be used
+            as to map values if :code:`map_values` is True.
+        :param map_values: A boolean flag that determines if the values should be
+            interpreted as a map
+        :param set_process: A function that takes a value and allows processing
+            before value mapping, returning the processed value
+        :param check_set_errors: Toggles checking errors after setting
+        :param dynamic: Specify whether the property parameters are meant to be changed in
+            instances or subclasses. See :meth:`control` for an usage example.
+        """
+
+        return CommonBase.control(get_command=None,
+                                  set_command=set_command,
+                                  docs=docs,
+                                  validator=validator,
+                                  values=values,
+                                  map_values=map_values,
+                                  set_process=set_process,
+                                  check_set_errors=check_set_errors,
+                                  dynamic=dynamic,
+                                  **kwargs)
diff --git a/pymeasure/instruments/instrument.py b/pymeasure/instruments/instrument.py
index 7b485dab..a014cb23 100644
--- a/pymeasure/instruments/instrument.py
+++ b/pymeasure/instruments/instrument.py
@@ -25,75 +25,14 @@
 import logging
 import time
 
-from pymeasure.adapters.visa import VISAAdapter
+from .common_base import CommonBase
+from ..adapters import VISAAdapter
 
 log = logging.getLogger(__name__)
 log.addHandler(logging.NullHandler())
 
 
-class DynamicProperty(property):
-    """ Class that allows managing python property behaviour in a "dynamic" fashion
-
-    The class allows passing, in addition to regular property parameters, a list of
-    runtime configurable parameters.
-    The effect is that the behaviour of fget/fset not only depends on the obj parameter, but
-    also on a set of keyword parameters with a default value.
-    These extra parameters are read from instance, if available, or left with the default value.
-    Dynamic behaviour is achieved by changing class or instance variables with special names
-    defined as `<prefix> + <property name> + <param name>`.
-
-    Code has been based on Python equivalent implementation of properties provided in the
-    python documentation `here <https://docs.python.org/3/howto/descriptor.html#properties>`_.
-
-    :param fget: class property fget parameter whose signature is expanded with a
-                 set of keyword arguments as in fget_params_list
-    :param fset: class property fget parameter whose signature is expanded with a
-                 set of keyword arguments as in fset_params_list
-    :param fdel: class property fdel parameter
-    :param doc: class property doc parameter
-    :param fget_params_list: List of parameter names that are dynamically configurable
-    :param fset_params_list: List of parameter names that are dynamically configurable
-    :param prefix: String to be prefixed to get dynamically configurable
-                   parameters.
-    """
-
-    def __init__(self, fget=None, fset=None, fdel=None, doc=None, fget_params_list=None,
-                 fset_params_list=None, prefix=""):
-        super().__init__(fget, fset, fdel, doc)
-        self.fget_params_list = () if fget_params_list is None else fget_params_list
-        self.fset_params_list = () if fset_params_list is None else fset_params_list
-        self.name = ""
-        self.prefix = prefix
-
-    def __get__(self, obj, objtype=None):
-        if obj is None:
-            # Property return itself when invoked from a class
-            return self
-        if self.fget is None:
-            raise AttributeError(f"Unreadable attribute {self.name}")
-
-        kwargs = {}
-        for attr in self.fget_params_list:
-            attr_instance_name = self.prefix + "_".join([self.name, attr])
-            if hasattr(obj, attr_instance_name):
-                kwargs[attr] = getattr(obj, attr_instance_name)
-        return self.fget(obj, **kwargs)
-
-    def __set__(self, obj, value):
-        if self.fset is None:
-            raise AttributeError(f"Can't set attribute {self.name}")
-        kwargs = {}
-        for attr in self.fset_params_list:
-            attr_instance_name = self.prefix + "_".join([self.name, attr])
-            if hasattr(obj, attr_instance_name):
-                kwargs[attr] = getattr(obj, attr_instance_name)
-        self.fset(obj, value, **kwargs)
-
-    def __set_name__(self, owner, name):
-        self.name = name
-
-
-class Instrument:
+class Instrument(CommonBase):
     """ The base class for all Instrument definitions.
 
     It makes use of one of the :py:class:`~pymeasure.adapters.Adapter` classes for communication
@@ -125,42 +64,22 @@ class Instrument:
         Discarded otherwise.
     """
 
-    # Variable holding the list of DynamicProperty parameters that are configurable
-    # by users
-    _fget_params_list = ('get_command',
-                         'values',
-                         'map_values',
-                         'get_process',
-                         'command_process',
-                         'check_get_errors')
-
-    _fset_params_list = ('set_command',
-                         'validator',
-                         'values',
-                         'map_values',
-                         'set_process',
-                         'command_process',
-                         'check_set_errors')
-
-    # Prefix used to store reserved variables
-    __reserved_prefix = "___"
-
     # noinspection PyPep8Naming
     def __init__(self, adapter, name, includeSCPI=True,
                  **kwargs):
+        # Setup communication before possible children require the adapter.
         if isinstance(adapter, (int, str)):
             try:
                 adapter = VISAAdapter(adapter, **kwargs)
             except ImportError:
                 raise Exception("Invalid Adapter provided for Instrument since"
                                 " PyVISA is not present")
-
-        self.name = name
-        self.SCPI = includeSCPI
         self.adapter = adapter
-
+        self.SCPI = includeSCPI
         self.isShutdown = False
-        self._special_names = self._setup_special_names()
+        self.name = name
+
+        super().__init__()
 
         log.info("Initializing %s." % self.name)
 
@@ -170,46 +89,7 @@ class Instrument:
     def __exit__(self, exc_type, exc_val, exc_tb):
         self.shutdown()
 
-    def _setup_special_names(self):
-        """ Return list of class/instance special names
-
-        Compute the list of special names based on the list of
-        class attributes that are a DynamicProperty. Check also for class variables
-        with special name and copy them at instance level
-        Internal method, not intended to be accessed at user level."""
-        special_names = []
-        dynamic_params = tuple(set(self._fget_params_list + self._fset_params_list))
-        # Check whether class variables of DynamicProperty type are present
-        for obj in (self,) + self.__class__.__mro__:
-            for attr_name, attr in obj.__dict__.items():
-                if isinstance(attr, DynamicProperty):
-                    special_names += [attr_name + "_" + key for key in dynamic_params]
-        # Check if special variables are defined at class level
-        for obj in (self,) + self.__class__.__mro__:
-            for attr in obj.__dict__:
-                if attr in special_names:
-                    # Copy class special variable at instance level, prefixing reserved_prefix
-                    setattr(self, self.__reserved_prefix + attr, obj.__dict__[attr])
-        return special_names
-
-    def __setattr__(self, name, value):
-        """ Add reserved_prefix in front of special variables """
-        if hasattr(self, '_special_names'):
-            if name in self._special_names:
-                name = self.__reserved_prefix + name
-        super().__setattr__(name, value)
-
-    def __getattribute__(self, name):
-        """ Prevent read access to variables with special names used to
-        support dynamic property behaviour """
-        if name in ('_special_names', '__dict__'):
-            return super().__getattribute__(name)
-        if hasattr(self, '_special_names'):
-            if name in self._special_names:
-                raise AttributeError(
-                    f"{name} is a reserved variable name and it cannot be read")
-        return super().__getattribute__(name)
-
+    # SCPI default properties
     @property
     def complete(self):
         """ This property allows synchronization between a controller and a device. The Operation Complete
@@ -281,6 +161,10 @@ class Instrument:
         """
         self.adapter.write_binary_values(command, values, *args, **kwargs)
 
+    def read_binary_values(self, **kwargs):
+        """Read binary values from the device."""
+        return self.adapter.read_binary_values(**kwargs)
+
     # Communication functions
     def wait_for(self, query_delay=0):
         """Wait for some time. Used by 'ask' to wait before reading.
@@ -290,278 +174,7 @@ class Instrument:
         if query_delay:
             time.sleep(query_delay)
 
-    def ask(self, command, query_delay=0):
-        """ Writes the command to the instrument through the adapter
-        and returns the read response.
-
-        :param command: Command string to be sent to the instrument.
-        :param query_delay: Delay between writing and reading in seconds.
-        :returns: String returned by the device without read_termination.
-        """
-        self.write(command)
-        self.wait_for(query_delay)
-        return self.read()
-
-    def values(self, command, separator=',', cast=float, preprocess_reply=None):
-        """ Writes a command to the instrument and returns a list of formatted
-        values from the result.
-
-        :param command: SCPI command to be sent to the instrument
-        :param separator: A separator character to split the string into a list
-        :param cast: A type to cast the result
-        :param preprocess_reply: optional callable used to preprocess values
-            received from the instrument. The callable returns the processed
-            string.
-        :returns: A list of the desired type, or strings where the casting fails
-        """
-        results = str(self.ask(command)).strip()
-        if callable(preprocess_reply):
-            results = preprocess_reply(results)
-        results = results.split(separator)
-        for i, result in enumerate(results):
-            try:
-                if cast == bool:
-                    # Need to cast to float first since results are usually
-                    # strings and bool of a non-empty string is always True
-                    results[i] = bool(float(result))
-                else:
-                    results[i] = cast(result)
-            except Exception:
-                pass  # Keep as string
-        return results
-
-    def binary_values(self, command, query_delay=0, **kwargs):
-        """ Returns a numpy array from a query for binary data.
-
-        :param command: Command to be sent to the instrument.
-        :param query_delay: Delay between writing and reading in seconds.
-        :param kwargs: Arguments for :meth:`Adapter.read_binary_values`.
-        :returns: NumPy array of values
-        """
-        self.write(command)
-        self.wait_for(query_delay)
-        return self.adapter.read_binary_values(**kwargs)
-
-    # Property creators
-    @staticmethod
-    def control(  # noqa: C901 accept that this is a complex method
-        get_command,
-        set_command,
-        docs,
-        validator=lambda v, vs: v,
-        values=(),
-        map_values=False,
-        get_process=lambda v: v,
-        set_process=lambda v: v,
-        command_process=lambda c: c,
-        check_set_errors=False,
-        check_get_errors=False,
-        dynamic=False,
-        **kwargs
-    ):
-        """Returns a property for the class based on the supplied
-        commands. This property may be set and read from the
-        instrument. See also :meth:`measurement` and :meth:`setting`.
-
-        :param get_command: A string command that asks for the value, set to `None`
-            if get is not supported (see also :meth:`setting`).
-        :param set_command: A string command that writes the value, set to `None`
-            if set is not supported (see also :meth:`measurement`).
-        :param docs: A docstring that will be included in the documentation
-        :param validator: A function that takes both a value and a group of valid values
-            and returns a valid value, while it otherwise raises an exception
-        :param values: A list, tuple, range, or dictionary of valid values, that can be used
-            as to map values if :code:`map_values` is True.
-        :param map_values: A boolean flag that determines if the values should be
-            interpreted as a map
-        :param get_process: A function that take a value and allows processing
-            before value mapping, returning the processed value
-        :param set_process: A function that takes a value and allows processing
-            before value mapping, returning the processed value
-        :param command_process: A function that takes a command and allows processing
-            before executing the command
-        :param check_set_errors: Toggles checking errors after setting
-        :param check_get_errors: Toggles checking errors after getting
-        :param dynamic: Specify whether the property parameters are meant to be changed in
-            instances or subclasses.
-
-        Example of usage of dynamic parameter is as follows:
-
-        .. code-block:: python
-
-            class GenericInstrument(Instrument):
-                center_frequency = Instrument.control(
-                    ":SENS:FREQ:CENT?;", ":SENS:FREQ:CENT %e GHz;",
-                    " A floating point property that represents the frequency ... ",
-                    validator=strict_range,
-                    # Redefine this in subclasses to reflect actual instrument value:
-                    values=(1, 20),
-                    dynamic=True  # enable changing property parameters on-the-fly
-                )
-
-            class SpecificInstrument(GenericInstrument):
-                # Identical to GenericInstrument, except for frequency range
-                # Override the "values" parameter of the "center_frequency" property
-                center_frequency_values = (1, 10) # Redefined at subclass level
-
-            instrument = SpecificInstrument()
-            instrument.center_frequency_values = (1, 6e9) # Redefined at instance level
-
-        .. warning:: Unexpected side effects when using dynamic properties
-
-        Users must pay attention when using dynamic properties, since definition of class and/or
-        instance attributes matching specific patterns could have unwanted side effect.
-        The attribute name pattern `property_param`, where `property` is the name of the dynamic
-        property (e.g. `center_frequency` in the example) and `param` is any of this method
-        parameters name except `dynamic` and `docs` (e.g. `values` in the example) has to be
-        considered reserved for dynamic property control.
-        """
-
-        def fget(self,
-                 get_command=get_command,
-                 values=values,
-                 map_values=map_values,
-                 get_process=get_process,
-                 command_process=command_process,
-                 check_get_errors=check_get_errors,
-                 ):
-            if get_command is None:
-                raise LookupError("Instrument property can not be read.")
-            vals = self.values(command_process(get_command), **kwargs)
-            if check_get_errors:
-                self.check_errors()
-            if len(vals) == 1:
-                value = get_process(vals[0])
-                if not map_values:
-                    return value
-                elif isinstance(values, (list, tuple, range)):
-                    return values[int(value)]
-                elif isinstance(values, dict):
-                    for k, v in values.items():
-                        if v == value:
-                            return k
-                    raise KeyError(f"Value {value} not found in mapped values")
-                else:
-                    raise ValueError(
-                        'Values of type `{}` are not allowed '
-                        'for Instrument.control'.format(type(values))
-                    )
-            else:
-                vals = get_process(vals)
-                return vals
-
-        def fset(self,
-                 value,
-                 set_command=set_command,
-                 validator=validator,
-                 values=values,
-                 map_values=map_values,
-                 set_process=set_process,
-                 command_process=command_process,
-                 check_set_errors=check_set_errors,
-                 ):
-
-            if set_command is None:
-                raise LookupError("Instrument property can not be set.")
-
-            value = set_process(validator(value, values))
-            if not map_values:
-                pass
-            elif isinstance(values, (list, tuple, range)):
-                value = values.index(value)
-            elif isinstance(values, dict):
-                value = values[value]
-            else:
-                raise ValueError(
-                    'Values of type `{}` are not allowed '
-                    'for Instrument.control'.format(type(values))
-                )
-            self.write(command_process(set_command) % value)
-            if check_set_errors:
-                self.check_errors()
-
-        # Add the specified document string to the getter
-        fget.__doc__ = docs
-
-        if dynamic:
-            fget.__doc__ += "(dynamic)"
-            return DynamicProperty(fget=fget, fset=fset,
-                                   fget_params_list=Instrument._fget_params_list,
-                                   fset_params_list=Instrument._fset_params_list,
-                                   prefix=Instrument.__reserved_prefix)
-        else:
-            return property(fget, fset)
-
-    @staticmethod
-    def measurement(get_command, docs, values=(), map_values=None,
-                    get_process=lambda v: v, command_process=lambda c: c,
-                    check_get_errors=False, dynamic=False, **kwargs):
-        """ Returns a property for the class based on the supplied
-        commands. This is a measurement quantity that may only be
-        read from the instrument, not set.
-
-        :param get_command: A string command that asks for the value
-        :param docs: A docstring that will be included in the documentation
-        :param values: A list, tuple, range, or dictionary of valid values, that can be used
-            as to map values if :code:`map_values` is True.
-        :param map_values: A boolean flag that determines if the values should be
-            interpreted as a map
-        :param get_process: A function that take a value and allows processing
-            before value mapping, returning the processed value
-        :param command_process: A function that take a command and allows processing
-            before executing the command, for getting
-        :param check_get_errors: Toggles checking errors after getting
-        :param dynamic: Specify whether the property parameters are meant to be changed in
-            instances or subclasses. See :meth:`control` for an usage example.
-        """
-
-        return Instrument.control(get_command=get_command,
-                                  set_command=None,
-                                  docs=docs,
-                                  values=values,
-                                  map_values=map_values,
-                                  get_process=get_process,
-                                  command_process=command_process,
-                                  check_get_errors=check_get_errors,
-                                  dynamic=dynamic,
-                                  **kwargs)
-
-    @staticmethod
-    def setting(set_command, docs,
-                validator=lambda x, y: x, values=(), map_values=False,
-                set_process=lambda v: v,
-                check_set_errors=False, dynamic=False,
-                **kwargs):
-        """Returns a property for the class based on the supplied
-        commands. This property may be set, but raises an exception
-        when being read from the instrument.
-
-        :param set_command: A string command that writes the value
-        :param docs: A docstring that will be included in the documentation
-        :param validator: A function that takes both a value and a group of valid values
-            and returns a valid value, while it otherwise raises an exception
-        :param values: A list, tuple, range, or dictionary of valid values, that can be used
-            as to map values if :code:`map_values` is True.
-        :param map_values: A boolean flag that determines if the values should be
-            interpreted as a map
-        :param set_process: A function that takes a value and allows processing
-            before value mapping, returning the processed value
-        :param check_set_errors: Toggles checking errors after setting
-        :param dynamic: Specify whether the property parameters are meant to be changed in
-            instances or subclasses. See :meth:`control` for an usage example.
-        """
-
-        return Instrument.control(get_command=None,
-                                  set_command=set_command,
-                                  docs=docs,
-                                  validator=validator,
-                                  values=values,
-                                  map_values=map_values,
-                                  set_process=set_process,
-                                  check_set_errors=check_set_errors,
-                                  dynamic=dynamic,
-                                  **kwargs)
-
+    # SCPI default methods
     def clear(self):
         """ Clears the instrument status byte
         """


Test patch:
diff --git a/tests/instruments/activetechnologies/test_AWG401x.py b/tests/instruments/activetechnologies/test_AWG401x.py
new file mode 100644
index 00000000..be93310c
--- /dev/null
+++ b/tests/instruments/activetechnologies/test_AWG401x.py
@@ -0,0 +1,150 @@
+#
+# This file is part of the PyMeasure package.
+#
+# Copyright (c) 2013-2022 PyMeasure Developers
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+
+from pymeasure.test import expected_protocol
+from pymeasure.instruments import Instrument
+
+from pymeasure.instruments.activetechnologies import AWG401x_AFG
+from pymeasure.instruments.activetechnologies.AWG401x import ChannelAFG, SequenceEntry
+
+
+class SequencerInstrument(Instrument):
+    """A class in order to test SequenceEntry."""
+
+    def __init__(self, adapter, **kwargs):
+        super().__init__(adapter, "SequencerInstrument", **kwargs)
+        self.waveforms = {}
+        self.se = SequenceEntry(self, 1, 7)
+
+
+# AFG Tests
+AFG_init_comm = [
+    # ("*IDN?", "x,AWG4012"),
+    ("SOURce1:INITDELay? MINimum", "1"),
+    ("SOURce1:INITDELay? MAXimum", "2"),
+    ("SOURce1:VOLTage:LEVel:IMMediate:LOW? MINimum", "1"),
+    ("SOURce1:VOLTage:LEVel:IMMediate:LOW? MAXimum", "2"),
+    ("SOURce1:VOLTage:LEVel:IMMediate:HIGH? MINimum", "1"),
+    ("SOURce1:VOLTage:LEVel:IMMediate:HIGH? MAXimum", "2"),
+    ("SOURce1:VOLTage:LEVel:IMMediate:AMPLitude? MINimum", "VPP1"),
+    ("SOURce1:VOLTage:LEVel:IMMediate:AMPLitude? MAXimum", "VPP2"),
+    ("SOURce1:VOLTage:LEVel:IMMediate:OFFSet? MINimum", "1"),
+    ("SOURce1:VOLTage:LEVel:IMMediate:OFFSet? MAXimum", "2"),
+    ("SOURce1:VOLTage:BASELINE:OFFSET? MINimum", "1"),
+    ("SOURce1:VOLTage:BASELINE:OFFSET? MAXimum", "2"),
+    ("SOURce1:FREQuency? MINimum", "1"),
+    ("SOURce1:FREQuency? MAXimum", "2"),
+    ("SOURce1:PHASe:ADJust? MINimum", "1"),
+    ("SOURce1:PHASe:ADJust? MAXimum", "2"),
+    ("SOURce2:INITDELay? MINimum", "1"),
+    ("SOURce2:INITDELay? MAXimum", "2"),
+    ("SOURce2:VOLTage:LEVel:IMMediate:LOW? MINimum", "1"),
+    ("SOURce2:VOLTage:LEVel:IMMediate:LOW? MAXimum", "2"),
+    ("SOURce2:VOLTage:LEVel:IMMediate:HIGH? MINimum", "1"),
+    ("SOURce2:VOLTage:LEVel:IMMediate:HIGH? MAXimum", "2"),
+    ("SOURce2:VOLTage:LEVel:IMMediate:AMPLitude? MINimum", "VPP1"),
+    ("SOURce2:VOLTage:LEVel:IMMediate:AMPLitude? MAXimum", "VPP2"),
+    ("SOURce2:VOLTage:LEVel:IMMediate:OFFSet? MINimum", "1"),
+    ("SOURce2:VOLTage:LEVel:IMMediate:OFFSet? MAXimum", "2"),
+    ("SOURce2:VOLTage:BASELINE:OFFSET? MINimum", "1"),
+    ("SOURce2:VOLTage:BASELINE:OFFSET? MAXimum", "2"),
+    ("SOURce2:FREQuency? MINimum", "1"),
+    ("SOURce2:FREQuency? MAXimum", "2"),
+    ("SOURce2:PHASe:ADJust? MINimum", "1"),
+    ("SOURce2:PHASe:ADJust? MAXimum", "2"),
+    ("*IDN?", "x,AWG4012"),
+]
+
+
+def test_AFG_init():
+    with expected_protocol(
+            AWG401x_AFG,
+            AFG_init_comm,
+    ) as inst:
+        assert len(inst.ch) == 2
+        assert isinstance(inst.ch[1], ChannelAFG)
+
+
+def test_AFG_frequency_setter():
+    with expected_protocol(
+            AWG401x_AFG,
+            [*AFG_init_comm,
+             ("SOURce2:FREQuency 1.5", None),
+             ],
+    ) as inst:
+        print(AWG401x_AFG.ch, inst.ch)
+        inst.ch[2].frequency = 1.5
+
+
+def test_AFG_frequency_getter():
+    with expected_protocol(
+            AWG401x_AFG,
+            [*AFG_init_comm,
+             ("SOURce2:FREQuency?", "1.5"),
+             ],
+    ) as inst:
+        assert inst.ch[2].frequency == 1.5
+
+
+# SequenceEntry Tests
+Sequence_init_comm = [
+    ("SEQuence:ELEM7:LENGth? MINimum", "1"),
+    ("SEQuence:ELEM7:LENGth? MAXimum", "2"),
+    ("SEQuence:ELEM7:LOOP:COUNt? MINimum", "1"),
+    ("SEQuence:ELEM7:LOOP:COUNt? MAXimum", "2"),
+    ("SEQuence:ELEM7:AMPlitude1? MINimum", "1"),
+    ("SEQuence:ELEM7:AMPlitude1? MAXimum", "2"),
+    ("SEQuence:ELEM7:OFFset1? MINimum", "1"),
+    ("SEQuence:ELEM7:OFFset1? MAXimum", "2"),
+    ("SEQuence:ELEM7:VOLTage:HIGH1? MINimum", "1"),
+    ("SEQuence:ELEM7:VOLTage:HIGH1? MAXimum", "2"),
+    ("SEQuence:ELEM7:VOLTage:LOW1? MINimum", "1"),
+    ("SEQuence:ELEM7:VOLTage:LOW1? MAXimum", "2"),
+]
+
+
+def test_SequenceEntry_init():
+    with expected_protocol(
+            SequencerInstrument,
+            Sequence_init_comm,
+    ):
+        pass  # verify init
+
+
+def test_SequenceEntry_voltage_amplitude_setter():
+    with expected_protocol(
+            SequencerInstrument,
+            [*Sequence_init_comm,
+             ("SEQuence:ELEM7:AMPlitude1 1.5", None)],
+    ) as inst:
+        inst.se.ch[1].voltage_amplitude = 1.5
+
+
+def test_SequenceEntry_voltage_amplitude_getter():
+    with expected_protocol(
+            SequencerInstrument,
+            [*Sequence_init_comm,
+             ("SEQuence:ELEM7:AMPlitude1?", "1.5")],
+    ) as inst:
+        assert inst.se.ch[1].voltage_amplitude == 1.5
diff --git a/tests/instruments/test_channel.py b/tests/instruments/test_channel.py
new file mode 100644
index 00000000..81acd143
--- /dev/null
+++ b/tests/instruments/test_channel.py
@@ -0,0 +1,199 @@
+#
+# This file is part of the PyMeasure package.
+#
+# Copyright (c) 2013-2022 PyMeasure Developers
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+
+from unittest import mock
+
+import pytest
+
+from pymeasure.test import expected_protocol
+from pymeasure.instruments import Channel, Instrument
+from pymeasure.instruments.validators import truncated_range
+
+
+class GenericChannel(Channel):
+    #  Use truncated_range as this easily lets us test for the range boundaries
+    fake_ctrl = Channel.control(
+        "C{ch}:control?", "C{ch}:control %d", "Control docs",
+        validator=truncated_range,
+        values=(1, 10),
+        dynamic=True,
+    )
+    fake_setting = Channel.setting(
+        "C{ch}:setting %d", "Set docs",
+        validator=truncated_range,
+        values=(1, 10),
+        dynamic=True,
+    )
+    fake_measurement = Channel.measurement(
+        "C{ch}:measurement?", "Measure docs",
+        values={'X': 1, 'Y': 2, 'Z': 3},
+        map_values=True,
+        dynamic=True,
+    )
+    special_control = Channel.control(
+        "SOUR{ch}:special?", "OUTP{ch}:special %s",
+        """Control with different channel specifiers for get and set.""",
+        cast=str,
+    )
+    check_errors_control = Channel.control(
+        "C{ch}:xy?", "C{ch}:xy %d",
+        """Control something, with error check on get and set.""",
+        check_set_errors=True,
+        check_get_errors=True,
+    )
+
+
+class ChannelWithPlaceholder(Channel):
+    """A test channel with a different placeholder"""
+
+    placeholder = "fn"
+
+    test = Channel.control("{fn}test?", "test{fn} %i", """Control test.""")
+
+
+class ChannelInstrument(Instrument):
+    def __init__(self, adapter, name="ChannelInstrument", **kwargs):
+        super().__init__(adapter, name, **kwargs)
+        self.errors = []
+        self.add_child(GenericChannel, "A")
+        self.add_child(GenericChannel, "B")
+
+    def check_errors(self):
+        err = self.values("SYST:ERR?")
+        if err:
+            self.errors.append(err)
+        return err
+
+
+class TestChannelCommunication:
+    @pytest.fixture()
+    def ch(self):
+        a = mock.MagicMock(return_value="5")
+        return Channel(a, "A")
+
+    def test_write(self, ch):
+        ch.write("abc")
+        assert ch.parent.method_calls == [mock.call.write('abc')]
+
+    def test_read(self, ch):
+        ch.read()
+        assert ch.parent.method_calls == [mock.call.read()]
+
+    def test_write_bytes(self, ch):
+        ch.write_bytes(b"abc")
+        assert ch.parent.method_calls == [mock.call.write_bytes(b"abc")]
+
+    def test_read_bytes(self, ch):
+        ch.read_bytes(5)
+        assert ch.parent.method_calls == [mock.call.read_bytes(5)]
+
+    def test_write_binary_values(self, ch):
+        ch.write_binary_values("abc", [5, 6, 7])
+        assert ch.parent.method_calls == [mock.call.write_binary_values("abc", [5, 6, 7])]
+
+    def test_read_binary_values(self, ch):
+        ch.read_binary_values()
+        assert ch.parent.method_calls == [mock.call.read_binary_values()]
+
+
+def test_channel_with_different_prefix():
+    c = ChannelWithPlaceholder(None, "A")
+    assert c.insert_id("id:{fn}") == "id:A"
+
+
+def test_channel_write():
+    with expected_protocol(ChannelInstrument, [("ChA:volt?", None)]) as inst:
+        inst.ch_A.write("Ch{ch}:volt?")
+
+
+def test_channel_write_name_twice():
+    """Verify, that any (i.e. more than one) occurrence of '{ch}' is changed."""
+    with expected_protocol(ChannelInstrument, [("ChA:volt:ChA?", None)]) as inst:
+        inst.ch_A.write("Ch{ch}:volt:Ch{ch}?")
+
+
+def test_channel_write_without_ch():
+    """Verify, that it is possible to send a command without '{ch}'."""
+    with expected_protocol(ChannelInstrument, [("Test", None)]) as inst:
+        inst.ch_A.write("Test")
+
+
+def test_channel_control():
+    with expected_protocol(
+            ChannelInstrument,
+            [("CA:control 7", None), ("CA:control?", "1.45")]
+    ) as inst:
+        inst.ch_A.fake_ctrl = 7
+        assert inst.ch_A.fake_ctrl == 1.45
+
+
+def test_channel_setting():
+    with expected_protocol(
+            ChannelInstrument,
+            [("CA:setting 3", None)]
+    ) as inst:
+        inst.ch_A.fake_setting = 3
+
+
+def test_channel_measurement():
+    with expected_protocol(
+            ChannelInstrument,
+            [("CA:measurement?", "2")]
+    ) as inst:
+        assert inst.ch_A.fake_measurement == "Y"
+
+
+def test_channel_dynamic_property():
+    with expected_protocol(ChannelInstrument, [("CA:control 100", None)]) as inst:
+        inst.ch_A.fake_ctrl_values = (1, 200)
+        # original values is (1, 10), therefore 100 should not be allowed.
+        inst.ch_A.fake_ctrl = 100
+
+
+def test_channel_special_control():
+    """Test different Prefixes for getter and setter."""
+    with expected_protocol(ChannelInstrument,
+                           [("SOURA:special?", "super"),
+                            ("OUTPB:special test", None)],
+                           ) as inst:
+        assert inst.ch_A.special_control == "super"
+        inst.ch_B.special_control = "test"
+
+
+def test_channel_check_set_errors():
+    with expected_protocol(ChannelInstrument,
+                           [("CA:xy 5", None),
+                            ("SYST:ERR?", "Some, error")],
+                           ) as inst:
+        inst.ch_A.check_errors_control = 5
+        assert inst.errors == [["Some", " error"]]
+
+
+def test_channel_check_get_errors():
+    with expected_protocol(ChannelInstrument,
+                           [("CA:xy?", "5"),
+                            ("SYST:ERR?", "Some, error")],
+                           ) as inst:
+        assert inst.ch_A.check_errors_control == 5
+        assert inst.errors == [["Some", " error"]]
diff --git a/tests/instruments/test_common_base.py b/tests/instruments/test_common_base.py
new file mode 100644
index 00000000..252d4754
--- /dev/null
+++ b/tests/instruments/test_common_base.py
@@ -0,0 +1,694 @@
+#
+# This file is part of the PyMeasure package.
+#
+# Copyright (c) 2013-2022 PyMeasure Developers
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#
+
+import pytest
+
+from pymeasure.units import ureg
+from pymeasure.test import expected_protocol
+from pymeasure.instruments.common_base import DynamicProperty, CommonBase
+from pymeasure.adapters import FakeAdapter, ProtocolAdapter
+from pymeasure.instruments.validators import strict_discrete_set, strict_range, truncated_range
+
+
+class CommonBaseTesting(CommonBase):
+    """Add read/write methods in order to use the ProtocolAdapter."""
+
+    def __init__(self, parent, id=None, *args, **kwargs):
+        super().__init__()
+        self.parent = parent
+        self.id = id
+        self.args = args
+        self.kwargs = kwargs
+
+    def wait_for(self, query_delay=0):
+        pass
+
+    def write(self, command):
+        self.parent.write(command)
+
+    def read(self):
+        return self.parent.read()
+
+
+class GenericBase(CommonBaseTesting):
+    #  Use truncated_range as this easily lets us test for the range boundaries
+    fake_ctrl = CommonBase.control(
+        "C{ch}:control?", "C{ch}:control %d", "docs",
+        validator=truncated_range,
+        values=(1, 10),
+        dynamic=True,
+    )
+    fake_setting = CommonBase.setting(
+        "C{ch}:setting %d", "docs",
+        validator=truncated_range,
+        values=(1, 10),
+        dynamic=True,
+    )
+    fake_measurement = CommonBase.measurement(
+        "C{ch}:measurement?", "docs",
+        values={'X': 1, 'Y': 2, 'Z': 3},
+        map_values=True,
+        dynamic=True,
+    )
+    special_control = CommonBase.control(
+        "SOUR{ch}:special?", "OUTP{ch}:special %s",
+        """A special control with different channel specifiers for get and set.""",
+        cast=str,
+    )
+
+
+@pytest.fixture()
+def generic():
+    return GenericBase()
+
+
+class FakeBase(CommonBaseTesting):
+    def __init__(self, *args, **kwargs):
+        super().__init__(FakeAdapter())
+
+    fake_ctrl = CommonBase.control(
+        "", "%d", "docs",
+        validator=truncated_range,
+        values=(1, 10),
+        dynamic=True,
+    )
+    fake_setting = CommonBase.setting(
+        "%d", "docs",
+        validator=truncated_range,
+        values=(1, 10),
+        dynamic=True,
+    )
+    fake_measurement = CommonBase.measurement(
+        "", "docs",
+        values={'X': 1, 'Y': 2, 'Z': 3},
+        map_values=True,
+        dynamic=True,
+    )
+
+
+@pytest.fixture()
+def fake():
+    return FakeBase()
+
+
+class ExtendedBase(FakeBase):
+    # Keep values unchanged, just derive another instrument, e.g. to add more properties
+    pass
+
+
+class StrictExtendedBase(ExtendedBase):
+    # Use strict instead of truncated range validator
+    fake_ctrl_validator = strict_range
+    fake_setting_validator = strict_range
+
+
+class NewRangeBase(FakeBase):
+    # Choose different properties' values, like you would for another device model
+    fake_ctrl_values = (10, 20)
+    fake_setting_values = (10, 20)
+    fake_measurement_values = {'X': 4, 'Y': 5, 'Z': 6}
+
+
+class Child(CommonBase):
+    """A child, which accepts parent and id arguments."""
+
+    def __init__(self, parent, id=None, *args, **kwargs):
+        super().__init__()
+
+
+class Parent(CommonBaseTesting):
+    """A Base as a parent"""
+    channels = CommonBase.ChannelCreator(GenericBase, ("A", "B", "C"))
+    analog = CommonBase.ChannelCreator(GenericBase, [1, 2], prefix="an_", test=True)
+    function = CommonBase.ChannelCreator(Child, prefix=None)
+
+
+# Test dynamic properties
+def test_dynamic_property_fget_unset():
+    d = DynamicProperty()
+    with pytest.raises(AttributeError, match="Unreadable attribute"):
+        d.__get__(5)
+
+
+def test_dynamic_property_fset_unset():
+    d = DynamicProperty()
+    with pytest.raises(AttributeError, match="set attribute"):
+        d.__set__(5, 7)
+
+
+# Test CommonBase Children management
+class TestInitWithChildren:
+    @pytest.fixture()
+    def parent(self):
+        return Parent(ProtocolAdapter())
+
+    def test_channels(self, parent):
+        assert len(parent.channels) == 3
+        assert isinstance(parent.ch_A, GenericBase)
+
+    def test_analog(self, parent):
+        assert len(parent.analog) == 2
+        assert parent.an_1 == parent.analog[1]
+        assert isinstance(parent.analog[1], GenericBase)
+
+    def test_function(self, parent):
+        assert isinstance(parent.function, Child)
+
+    def test_removal_of_protected_children_fails(self, parent):
+        with pytest.raises(TypeError, match="cannot remove channels defined at class"):
+            parent.remove_child(parent.ch_A)
+
+    def test_removal_of_protected_single_children_fails(self, parent):
+        with pytest.raises(TypeError, match="cannot remove channels defined at class"):
+            parent.remove_child(parent.function)
+
+    def test_channel_creation_works_more_than_once(self):
+        """A zipper object works just once, ensure that a class may be used more often."""
+        p1 = Parent(ProtocolAdapter())  # first instance of that class
+        assert isinstance(p1.analog[1], GenericBase)  # verify that it worked once
+        p2 = Parent(ProtocolAdapter())  # second instance of that class
+        assert isinstance(p2.analog[1], GenericBase)  # verify that it worked a second time
+
+
+class TestAddChild:
+    """Test the `add_child` method"""
+    @pytest.fixture()
+    def parent(self):
+        parent = CommonBaseTesting(ProtocolAdapter())
+        parent.add_child(GenericBase, "A", test=5)
+        parent.add_child(GenericBase, "B")
+        parent.add_child(GenericBase, prefix=None, collection="function")
+        return parent
+
+    def test_correct_class(self, parent):
+        assert isinstance(parent.ch_A, GenericBase)
+
+    def test_arguments(self, parent):
+        assert parent.channels["A"].id == "A"
+        assert parent.channels["A"].kwargs == {'test': 5}
+
+    def test_attribute_access(self, parent):
+        assert parent.ch_B == parent.channels["B"]
+
+    def test_len(self, parent):
+        assert len(parent.channels) == 2
+
+    def test_attributes(self, parent):
+        assert parent.ch_A._name == "ch_A"
+        assert parent.ch_B._collection == "channels"
+
+    def test_overwriting_list_raises_error(self, parent):
+        """A single channel is only allowed, if there is no list of that name."""
+        with pytest.raises(ValueError, match="already exists"):
+            parent.add_child(GenericBase, prefix=None)
+
+    def test_single_channel(self, parent):
+        """Test, that id=None creates a single channel."""
+        assert isinstance(parent.function, GenericBase)
+        assert parent.function._name == "function"
+
+    def test_evaluating_false_id_creates_channels(self, parent):
+        """Test that an id evaluating false (e.g. 0) creates a channels list."""
+        parent.add_child(GenericBase, 0, collection="special")
+        assert isinstance(parent.special, dict)
+
+
+class TestRemoveChild:
+    @pytest.fixture()
+    def parent_without_children(self):
+        parent = CommonBaseTesting(ProtocolAdapter())
+        parent.add_child(GenericBase, "A")
+        parent.add_child(GenericBase, "B")
+        parent.add_child(GenericBase, prefix=None, collection="function")
+        parent.remove_child(parent.ch_A)
+        parent.remove_child(parent.channels["B"])
+        parent.remove_child(parent.function)
+        return parent
+
+    def test_remove_child_leaves_channels_empty(self, parent_without_children):
+        assert parent_without_children.channels == {}
+
+    def test_remove_child_clears_attributes(self, parent_without_children):
+        assert getattr(parent_without_children, "ch_A", None) is None
+        assert getattr(parent_without_children, "ch_B", None) is None
+        assert getattr(parent_without_children, "function", None) is None
+
+
+# Test ChildDescriptor
+@pytest.mark.parametrize("args, pairs, kwargs", (
+    ((Child, ["A", "B"]), [(Child, "A"), (Child, "B")], {'prefix': "ch_"}),
+    (((Child, GenericBase, Child), (1, 2, 3)),
+     [(Child, 1), (GenericBase, 2), (Child, 3)], {'prefix': "ch_"}),
+    ((Child, "mm", None), [(Child, "mm")], {'prefix': None}),
+    ((Child, None, None), [(Child, None)], {'prefix': None}),
+))
+def test_ChannelCreator(args, pairs, kwargs):
+    """Test whether the channel creator receives the right arguments."""
+    d = CommonBase.ChannelCreator(*args)
+    i = 0
+    for pair in d.pairs:
+        assert pair == pairs[i]
+        i += 1
+    assert d.kwargs == kwargs
+
+
+def test_ChannelCreator_different_list_lengths():
+    with pytest.raises(AssertionError, match="Lengths"):
+        CommonBase.ChannelCreator(("A", "B", "C"), (Child,) * 2)
+
+
+def test_ChannelCreator_invalid_input():
+    with pytest.raises(ValueError, match="Invalid"):
+        CommonBase.ChannelCreator("A", {})
+
+
+# Test CommonBase communication
+def test_ask_writes_and_reads():
+    with expected_protocol(CommonBaseTesting, [("Sent", "Received")]) as inst:
+        assert inst.ask("Sent") == "Received"
+
+
+@pytest.mark.parametrize("value, kwargs, result",
+                         (("5,6,7", {}, [5, 6, 7]),
+                          ("5.6.7", {'separator': '.'}, [5, 6, 7]),
+                          ("5,6,7", {'cast': str}, ['5', '6', '7']),
+                          ("X,Y,Z", {}, ['X', 'Y', 'Z']),
+                          ("X,Y,Z", {'cast': str}, ['X', 'Y', 'Z']),
+                          ("X.Y.Z", {'separator': '.'}, ['X', 'Y', 'Z']),
+                          ("0,5,7.1", {'cast': bool}, [False, True, True]),
+                          ("x5x", {'preprocess_reply': lambda v: v.strip("x")}, [5])
+                          ))
+def test_values(value, kwargs, result):
+    cb = CommonBaseTesting(FakeAdapter(), "test")
+    assert cb.values(value, **kwargs) == result
+
+
+def test_binary_values(fake):
+    fake.read_binary_values = fake.read
+    assert fake.binary_values("123") == "123"
+
+
+# Test CommonBase property creators
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_control_doc(dynamic):
+    doc = """ X property """
+
+    class Fake(CommonBaseTesting):
+        x = CommonBase.control(
+            "", "%d", doc,
+            dynamic=dynamic
+        )
+
+    expected_doc = doc + "(dynamic)" if dynamic else doc
+    assert Fake.x.__doc__ == expected_doc
+
+
+def test_control_check_errors_get(fake):
+    fake.fake_ctrl_check_get_errors = True
+
+    def checking():
+        fake.error = True
+    fake.check_errors = checking
+    fake.fake_ctrl
+    assert fake.error is True
+
+
+def test_control_check_errors_set(fake):
+    fake.fake_ctrl_check_set_errors = True
+
+    def checking():
+        fake.error = True
+    fake.check_errors = checking
+    fake.fake_ctrl = 7
+    assert fake.error is True
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_control_validator(dynamic):
+    class Fake(FakeBase):
+        x = CommonBase.control(
+            "", "%d", "",
+            validator=strict_discrete_set,
+            values=range(10),
+            dynamic=dynamic
+        )
+
+    fake = Fake()
+    fake.x = 5
+    assert fake.read() == '5'
+    fake.x = 5
+    assert fake.x == 5
+    with pytest.raises(ValueError):
+        fake.x = 20
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_control_validator_map(dynamic):
+    class Fake(FakeBase):
+        x = CommonBase.control(
+            "", "%d", "",
+            validator=strict_discrete_set,
+            values=[4, 5, 6, 7],
+            map_values=True,
+            dynamic=dynamic
+        )
+
+    fake = Fake()
+    fake.x = 5
+    assert fake.read() == '1'
+    fake.x = 5
+    assert fake.x == 5
+    with pytest.raises(ValueError):
+        fake.x = 20
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_control_dict_map(dynamic):
+    class Fake(FakeBase):
+        x = CommonBase.control(
+            "", "%d", "",
+            validator=strict_discrete_set,
+            values={5: 1, 10: 2, 20: 3},
+            map_values=True,
+            dynamic=dynamic
+        )
+
+    fake = Fake()
+    fake.x = 5
+    assert fake.read() == '1'
+    fake.x = 5
+    assert fake.x == 5
+    fake.x = 20
+    assert fake.read() == '3'
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_control_dict_str_map(dynamic):
+    class Fake(FakeBase):
+        x = CommonBase.control(
+            "", "%d", "",
+            validator=strict_discrete_set,
+            values={'X': 1, 'Y': 2, 'Z': 3},
+            map_values=True,
+            dynamic=dynamic,
+        )
+
+    fake = Fake()
+    fake.x = 'X'
+    assert fake.read() == '1'
+    fake.x = 'Y'
+    assert fake.x == 'Y'
+    fake.x = 'Z'
+    assert fake.read() == '3'
+
+
+def test_value_not_in_map(fake):
+    fake.parent._buffer = "123"
+    with pytest.raises(KeyError, match="not found in mapped values"):
+        fake.fake_measurement
+
+
+def test_control_invalid_values_get():
+    class Fake(FakeBase):
+        x = CommonBase.control(
+            "", "%d", "",
+            values=b"abasdfe", map_values=True)
+    with pytest.raises(ValueError, match="Values of type"):
+        Fake().x
+
+
+def test_control_invalid_values_set():
+    class Fake(FakeBase):
+        x = CommonBase.control(
+            "", "%d", "",
+            values=b"abasdfe", map_values=True)
+    with pytest.raises(ValueError, match="Values of type"):
+        Fake().x = 7
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_control_process(dynamic):
+    class Fake(FakeBase):
+        x = CommonBase.control(
+            "", "%d", "",
+            validator=strict_range,
+            values=[5e-3, 120e-3],
+            get_process=lambda v: v * 1e-3,
+            set_process=lambda v: v * 1e3,
+            dynamic=dynamic,
+        )
+
+    fake = Fake()
+    fake.x = 10e-3
+    assert fake.read() == '10'
+    fake.x = 30e-3
+    assert fake.x == 30e-3
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_control_get_process(dynamic):
+    class Fake(FakeBase):
+        x = CommonBase.control(
+            "", "JUNK%d", "",
+            validator=strict_range,
+            values=[0, 10],
+            get_process=lambda v: int(v.replace('JUNK', '')),
+            dynamic=dynamic,
+        )
+
+    fake = Fake()
+    fake.x = 5
+    assert fake.read() == 'JUNK5'
+    fake.x = 5
+    assert fake.x == 5
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_control_preprocess_reply_property(dynamic):
+    # test setting preprocess_reply at property-level
+    class Fake(FakeBase):
+        x = CommonBase.control(
+            "", "JUNK%d",
+            "",
+            preprocess_reply=lambda v: v.replace('JUNK', ''),
+            dynamic=dynamic,
+            cast=int,
+        )
+
+    fake = Fake()
+    fake.x = 5
+    assert fake.read() == 'JUNK5'
+    # notice that read returns the full reply since preprocess_reply is only
+    # called inside CommonBase.values()
+    fake.x = 5
+    assert fake.x == 5
+    fake.x = 5
+    assert type(fake.x) == int
+
+
+@pytest.mark.parametrize("cast, expected", ((float, 5.5),
+                                            (ureg.Quantity, ureg.Quantity(5.5)),
+                                            (str, "5.5"),
+                                            (lambda v: int(float(v)), 5)
+                                            ))
+def test_measurement_cast(cast, expected):
+    class Fake(CommonBaseTesting):
+        x = CommonBase.measurement(
+            "x", "doc", cast=cast)
+    with expected_protocol(Fake, [("x", "5.5")], name="test") as instr:
+        assert instr.x == expected
+
+
+def test_measurement_cast_int():
+    class Fake(CommonBaseTesting):
+        def __init__(self, adapter, **kwargs):
+            super().__init__(adapter, "test", **kwargs)
+        x = CommonBase.measurement(
+            "x", "doc", cast=int)
+    with expected_protocol(Fake, [("x", "5")]) as instr:
+        y = instr.x
+        assert y == 5
+        assert type(y) is int
+
+
+def test_measurement_unitful_property():
+    class Fake(CommonBaseTesting):
+        def __init__(self, adapter, **kwargs):
+            super().__init__(adapter, "test", **kwargs)
+        x = CommonBase.measurement(
+            "x", "doc", get_process=lambda v: ureg.Quantity(v, ureg.m))
+    with expected_protocol(Fake, [("x", "5.5")]) as instr:
+        y = instr.x
+        assert y.m_as(ureg.m) == 5.5
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_measurement_dict_str_map(dynamic):
+    class Fake(FakeBase):
+        x = CommonBase.measurement(
+            "", "",
+            values={'X': 1, 'Y': 2, 'Z': 3},
+            map_values=True,
+            dynamic=dynamic,
+        )
+
+    fake = Fake()
+    fake.write('1')
+    assert fake.x == 'X'
+    fake.write('2')
+    assert fake.x == 'Y'
+    fake.write('3')
+    assert fake.x == 'Z'
+
+
+def test_measurement_set(fake):
+    with pytest.raises(LookupError, match="Property can not be set."):
+        fake.fake_measurement = 7
+
+
+def test_setting_get(fake):
+    with pytest.raises(LookupError, match="Property can not be read."):
+        fake.fake_setting
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_setting_process(dynamic):
+    class Fake(FakeBase):
+        x = CommonBase.setting(
+            "OUT %d", "",
+            set_process=lambda v: int(bool(v)),
+            dynamic=dynamic,
+        )
+
+    fake = Fake()
+    fake.x = False
+    assert fake.read() == 'OUT 0'
+    fake.x = 2
+    assert fake.read() == 'OUT 1'
+
+
+@pytest.mark.parametrize("dynamic", [False, True])
+def test_control_multivalue(dynamic):
+    class Fake(FakeBase):
+        x = CommonBase.control(
+            "", "%d,%d", "",
+            dynamic=dynamic,
+        )
+
+    fake = Fake()
+    fake.x = (5, 6)
+    assert fake.read() == '5,6'
+
+
+@pytest.mark.parametrize(
+    'set_command, given, expected, dynamic',
+    [("%d", 5, 5, False),
+     ("%d", 5, 5, True),
+     ("%d, %d", (5, 6), [5, 6], False),  # input has to be a tuple, not a list
+     ("%d, %d", (5, 6), [5, 6], True),  # input has to be a tuple, not a list
+     ])
+def test_FakeBase_control(set_command, given, expected, dynamic):
+    """FakeBase's custom simple control needs to process values correctly.
+    """
+    class Fake(FakeBase):
+        x = FakeBase.control(
+            "", set_command, "",
+            dynamic=dynamic,
+        )
+
+    fake = Fake()
+    fake.x = given
+    assert fake.x == expected
+
+
+def test_dynamic_property_unchanged_by_inheritance():
+    generic = FakeBase()
+    extended = ExtendedBase()
+
+    generic.fake_ctrl = 50
+    assert generic.fake_ctrl == 10
+    extended.fake_ctrl = 50
+    assert extended.fake_ctrl == 10
+
+    generic.fake_setting = 50
+    assert generic.read() == '10'
+    extended.fake_setting = 50
+    assert extended.read() == '10'
+
+    generic.write('1')
+    assert generic.fake_measurement == 'X'
+    extended.write('1')
+    assert extended.fake_measurement == 'X'
+
+
+def test_dynamic_property_strict_raises():
+    strict = StrictExtendedBase()
+
+    with pytest.raises(ValueError):
+        strict.fake_ctrl = 50
+    with pytest.raises(ValueError):
+        strict.fake_setting = 50
+
+
+def test_dynamic_property_values_update_in_subclass():
+    newrange = NewRangeBase()
+
+    newrange.fake_ctrl = 50
+    assert newrange.fake_ctrl == 20
+
+    newrange.fake_setting = 50
+    assert newrange.read() == '20'
+
+    newrange.write('4')
+    assert newrange.fake_measurement == 'X'
+
+
+def test_dynamic_property_values_update_in_instance(fake):
+    fake.fake_ctrl_values = (0, 33)
+    fake.fake_ctrl = 50
+    assert fake.fake_ctrl == 33
+
+    fake.fake_setting_values = (0, 33)
+    fake.fake_setting = 50
+    assert fake.read() == '33'
+
+    fake.fake_measurement_values = {'X': 7}
+    fake.write('7')
+    assert fake.fake_measurement == 'X'
+
+
+def test_dynamic_property_values_update_in_one_instance_leaves_other_unchanged():
+    generic1 = FakeBase()
+    generic2 = FakeBase()
+
+    generic1.fake_ctrl_values = (0, 33)
+    generic1.fake_ctrl = 50
+    generic2.fake_ctrl = 50
+    assert generic1.fake_ctrl == 33
+    assert generic2.fake_ctrl == 10
+
+
+def test_dynamic_property_reading_special_attributes_forbidden(fake):
+    with pytest.raises(AttributeError):
+        fake.fake_ctrl_validator
diff --git a/tests/instruments/test_instrument.py b/tests/instruments/test_instrument.py
index ae1e084f..2bbcfbd4 100644
--- a/tests/instruments/test_instrument.py
+++ b/tests/instruments/test_instrument.py
@@ -22,18 +22,17 @@
 # THE SOFTWARE.
 #
 
+
 import time
 from unittest import mock
 
 import pytest
 
-from pymeasure.units import ureg
 from pymeasure.test import expected_protocol
-from pymeasure.instruments import Instrument
-from pymeasure.instruments.instrument import DynamicProperty
+from pymeasure.instruments import Instrument, Channel
 from pymeasure.adapters import FakeAdapter, ProtocolAdapter
 from pymeasure.instruments.fakes import FakeInstrument
-from pymeasure.instruments.validators import strict_discrete_set, strict_range, truncated_range
+from pymeasure.instruments.validators import truncated_range
 
 
 class GenericInstrument(FakeInstrument):
@@ -58,27 +57,43 @@ class GenericInstrument(FakeInstrument):
     )
 
 
-class ExtendedInstrument(GenericInstrument):
-    # Keep values unchanged, just derive another instrument, e.g. to add more properties
-    pass
-
-
-class StrictExtendedInstrument(ExtendedInstrument):
-    # Use strict instead of truncated range validator
-    fake_ctrl_validator = strict_range
-    fake_setting_validator = strict_range
-
-
 @pytest.fixture()
 def generic():
     return GenericInstrument()
 
 
-class NewRangeInstrument(GenericInstrument):
-    # Choose different properties' values, like you would for another device model
-    fake_ctrl_values = (10, 20)
-    fake_setting_values = (10, 20)
-    fake_measurement_values = {'X': 4, 'Y': 5, 'Z': 6}
+class GenericChannel(Channel):
+    #  Use truncated_range as this easily lets us test for the range boundaries
+    fake_ctrl = Instrument.control(
+        "C{ch}:control?", "C{ch}:control %d", "docs",
+        validator=truncated_range,
+        values=(1, 10),
+        dynamic=True,
+    )
+    fake_setting = Instrument.setting(
+        "C{ch}:setting %d", "docs",
+        validator=truncated_range,
+        values=(1, 10),
+        dynamic=True,
+    )
+    fake_measurement = Instrument.measurement(
+        "C{ch}:measurement?", "docs",
+        values={'X': 1, 'Y': 2, 'Z': 3},
+        map_values=True,
+        dynamic=True,
+    )
+    special_control = Instrument.control(
+        "SOUR{ch}:special?", "OUTP{ch}:special %s",
+        """A special control with different channel specifiers for get and set.""",
+        cast=str,
+    )
+
+
+class ChannelInstrument(Instrument):
+    def __init__(self, adapter, name="ChannelInstrument", **kwargs):
+        super().__init__(adapter, name, **kwargs)
+        self.add_child(GenericChannel, "A")
+        self.add_child(GenericChannel, "B")
 
 
 def test_fake_instrument():
@@ -107,6 +122,19 @@ def test_instrument_in_context():
     assert instr.isShutdown is True
 
 
+def test_with_statement():
+    """ Test the with-statement-behaviour of the instruments. """
+    with FakeInstrument() as fake:
+        # Check if fake is an instance of FakeInstrument
+        assert isinstance(fake, FakeInstrument)
+
+        # Check whether the shutdown function is already called
+        assert fake.isShutdown is False
+
+    # Check whether the shutdown function is called upon
+    assert fake.isShutdown is True
+
+
 class TestInstrumentCommunication:
     @pytest.fixture()
     def instr(self):
@@ -205,429 +233,40 @@ def test_SCPI_false_raises_errors(method):
         getattr(Instrument(FakeAdapter(), "abc", includeSCPI=False), method)()
 
 
-@pytest.mark.parametrize("value, kwargs, result",
-                         (("5,6,7", {}, [5, 6, 7]),
-                          ("5.6.7", {'separator': '.'}, [5, 6, 7]),
-                          ("5,6,7", {'cast': str}, ['5', '6', '7']),
-                          ("X,Y,Z", {}, ['X', 'Y', 'Z']),
-                          ("X,Y,Z", {'cast': str}, ['X', 'Y', 'Z']),
-                          ("X.Y.Z", {'separator': '.'}, ['X', 'Y', 'Z']),
-                          ("0,5,7.1", {'cast': bool}, [False, True, True]),
-                          ("x5x", {'preprocess_reply': lambda v: v.strip("x")}, [5])
-                          ))
-def test_values(value, kwargs, result):
-    instr = Instrument(FakeAdapter(), "test")
-    assert instr.values(value, **kwargs) == result
-
-
-# Testing Instrument.control
-@pytest.mark.parametrize("dynamic", [False, True])
-def test_control_doc(dynamic):
-    doc = """ X property """
-
-    class Fake(Instrument):
-        x = Instrument.control(
-            "", "%d", doc,
-            dynamic=dynamic
-        )
-
-    expected_doc = doc + "(dynamic)" if dynamic else doc
-    assert Fake.x.__doc__ == expected_doc
-
-
-def test_control_check_errors_get(generic):
-    generic.fake_ctrl_check_get_errors = True
-
-    def checking():
-        generic.error = True
-    generic.check_errors = checking
-    generic.fake_ctrl
-    assert generic.error is True
-
-
-def test_control_check_errors_set(generic):
-    generic.fake_ctrl_check_set_errors = True
-
-    def checking():
-        generic.error = True
-    generic.check_errors = checking
-    generic.fake_ctrl = 7
-    assert generic.error is True
-
-
-@pytest.mark.parametrize("dynamic", [False, True])
-def test_control_validator(dynamic):
-    class Fake(FakeInstrument):
-        x = Instrument.control(
-            "", "%d", "",
-            validator=strict_discrete_set,
-            values=range(10),
-            dynamic=dynamic
-        )
-
-    fake = Fake()
-    fake.x = 5
-    assert fake.read() == '5'
-    fake.x = 5
-    assert fake.x == 5
-    with pytest.raises(ValueError):
-        fake.x = 20
-
-
-@pytest.mark.parametrize("dynamic", [False, True])
-def test_control_validator_map(dynamic):
-    class Fake(FakeInstrument):
-        x = Instrument.control(
-            "", "%d", "",
-            validator=strict_discrete_set,
-            values=[4, 5, 6, 7],
-            map_values=True,
-            dynamic=dynamic
-        )
-
-    fake = Fake()
-    fake.x = 5
-    assert fake.read() == '1'
-    fake.x = 5
-    assert fake.x == 5
-    with pytest.raises(ValueError):
-        fake.x = 20
-
-
-@pytest.mark.parametrize("dynamic", [False, True])
-def test_control_dict_map(dynamic):
-    class Fake(FakeInstrument):
-        x = Instrument.control(
-            "", "%d", "",
-            validator=strict_discrete_set,
-            values={5: 1, 10: 2, 20: 3},
-            map_values=True,
-            dynamic=dynamic
-        )
-
-    fake = Fake()
-    fake.x = 5
-    assert fake.read() == '1'
-    fake.x = 5
-    assert fake.x == 5
-    fake.x = 20
-    assert fake.read() == '3'
-
-
-@pytest.mark.parametrize("dynamic", [False, True])
-def test_control_dict_str_map(dynamic):
-    class Fake(FakeInstrument):
-        x = Instrument.control(
-            "", "%d", "",
-            validator=strict_discrete_set,
-            values={'X': 1, 'Y': 2, 'Z': 3},
-            map_values=True,
-            dynamic=dynamic,
-        )
-
-    fake = Fake()
-    fake.x = 'X'
-    assert fake.read() == '1'
-    fake.x = 'Y'
-    assert fake.x == 'Y'
-    fake.x = 'Z'
-    assert fake.read() == '3'
-
-
-def test_value_not_in_map(generic):
-    generic.adapter._buffer = "123"
-    with pytest.raises(KeyError, match="not found in mapped values"):
-        generic.fake_measurement
-
-
-def test_control_invalid_values_get():
-    class Fake(FakeInstrument):
-        x = Instrument.control(
-            "", "%d", "",
-            values=b"abasdfe", map_values=True)
-    with pytest.raises(ValueError, match="Values of type"):
-        Fake().x
-
-
-def test_control_invalid_values_set():
-    class Fake(FakeInstrument):
-        x = Instrument.control(
-            "", "%d", "",
-            values=b"abasdfe", map_values=True)
-    with pytest.raises(ValueError, match="Values of type"):
-        Fake().x = 7
-
-
-@pytest.mark.parametrize("dynamic", [False, True])
-def test_control_process(dynamic):
-    class Fake(FakeInstrument):
-        x = Instrument.control(
-            "", "%d", "",
-            validator=strict_range,
-            values=[5e-3, 120e-3],
-            get_process=lambda v: v * 1e-3,
-            set_process=lambda v: v * 1e3,
-            dynamic=dynamic,
-        )
-
-    fake = Fake()
-    fake.x = 10e-3
-    assert fake.read() == '10'
-    fake.x = 30e-3
-    assert fake.x == 30e-3
-
-
-@pytest.mark.parametrize("dynamic", [False, True])
-def test_control_get_process(dynamic):
-    class Fake(FakeInstrument):
-        x = Instrument.control(
-            "", "JUNK%d", "",
-            validator=strict_range,
-            values=[0, 10],
-            get_process=lambda v: int(v.replace('JUNK', '')),
-            dynamic=dynamic,
-        )
-
-    fake = Fake()
-    fake.x = 5
-    assert fake.read() == 'JUNK5'
-    fake.x = 5
-    assert fake.x == 5
-
-
-@pytest.mark.parametrize("dynamic", [False, True])
-def test_control_preprocess_reply_property(dynamic):
-    # test setting preprocess_reply at property-level
-    class Fake(FakeInstrument):
-        x = Instrument.control(
-            "", "JUNK%d",
-            "",
-            preprocess_reply=lambda v: v.replace('JUNK', ''),
-            dynamic=dynamic,
-            cast=int,
-        )
-
-    fake = Fake()
-    fake.x = 5
-    assert fake.read() == 'JUNK5'
-    # notice that read returns the full reply since preprocess_reply is only
-    # called inside Instrument.values()
-    fake.x = 5
-    assert fake.x == 5
-    fake.x = 5
-    assert type(fake.x) == int
-
-
-@pytest.mark.parametrize("cast, expected", ((float, 5.5),
-                                            (ureg.Quantity, ureg.Quantity(5.5)),
-                                            (str, "5.5"),
-                                            (lambda v: int(float(v)), 5)
-                                            ))
-def test_measurement_cast(cast, expected):
-    class Fake(Instrument):
-        x = Instrument.measurement(
-            "x", "doc", cast=cast)
-    with expected_protocol(Fake, [("x", "5.5")], name="test") as instr:
-        assert instr.x == expected
-
-
-def test_measurement_cast_int():
-    class Fake(Instrument):
-        def __init__(self, adapter, **kwargs):
-            super().__init__(adapter, "test", **kwargs)
-        x = Instrument.measurement(
-            "x", "doc", cast=int)
-    with expected_protocol(Fake, [("x", "5")]) as instr:
-        y = instr.x
-        assert y == 5
-        assert type(y) is int
-
-
-def test_measurement_unitful_property():
-    class Fake(Instrument):
-        def __init__(self, adapter, **kwargs):
-            super().__init__(adapter, "test", **kwargs)
-        x = Instrument.measurement(
-            "x", "doc", get_process=lambda v: ureg.Quantity(v, ureg.m))
-    with expected_protocol(Fake, [("x", "5.5")]) as instr:
-        y = instr.x
-        assert y.m_as(ureg.m) == 5.5
-
-
-@pytest.mark.parametrize("dynamic", [False, True])
-def test_measurement_dict_str_map(dynamic):
-    class Fake(FakeInstrument):
-        x = Instrument.measurement(
-            "", "",
-            values={'X': 1, 'Y': 2, 'Z': 3},
-            map_values=True,
-            dynamic=dynamic,
-        )
-
-    fake = Fake()
-    fake.write('1')
-    assert fake.x == 'X'
-    fake.write('2')
-    assert fake.x == 'Y'
-    fake.write('3')
-    assert fake.x == 'Z'
-
-
-def test_measurement_set(generic):
-    with pytest.raises(LookupError, match="Instrument property can not be set."):
-        generic.fake_measurement = 7
-
-
-def test_setting_get(generic):
-    with pytest.raises(LookupError, match="Instrument property can not be read."):
-        generic.fake_setting
-
-
-@pytest.mark.parametrize("dynamic", [False, True])
-def test_setting_process(dynamic):
-    class Fake(FakeInstrument):
-        x = Instrument.setting(
-            "OUT %d", "",
-            set_process=lambda v: int(bool(v)),
-            dynamic=dynamic,
-        )
-
-    fake = Fake()
-    fake.x = False
-    assert fake.read() == 'OUT 0'
-    fake.x = 2
-    assert fake.read() == 'OUT 1'
-
-
-@pytest.mark.parametrize("dynamic", [False, True])
-def test_control_multivalue(dynamic):
-    class Fake(FakeInstrument):
-        x = Instrument.control(
-            "", "%d,%d", "",
-            dynamic=dynamic,
-        )
-
-    fake = Fake()
-    fake.x = (5, 6)
-    assert fake.read() == '5,6'
-
-
-@pytest.mark.parametrize(
-    'set_command, given, expected, dynamic',
-    [("%d", 5, 5, False),
-     ("%d", 5, 5, True),
-     ("%d, %d", (5, 6), [5, 6], False),  # input has to be a tuple, not a list
-     ("%d, %d", (5, 6), [5, 6], True),  # input has to be a tuple, not a list
-     ])
-def test_fakeinstrument_control(set_command, given, expected, dynamic):
-    """FakeInstrument's custom simple control needs to process values correctly.
-    """
-    class Fake(FakeInstrument):
-        x = FakeInstrument.control(
-            "", set_command, "",
-            dynamic=dynamic,
-        )
-
-    fake = Fake()
-    fake.x = given
-    assert fake.x == expected
-
-
-def test_with_statement():
-    """ Test the with-statement-behaviour of the instruments. """
-    with FakeInstrument() as fake:
-        # Check if fake is an instance of FakeInstrument
-        assert isinstance(fake, FakeInstrument)
-
-        # Check whether the shutdown function is already called
-        assert fake.isShutdown is False
-
-    # Check whether the shutdown function is called upon
-    assert fake.isShutdown is True
-
-
-def test_dynamic_property_fget_unset():
-    d = DynamicProperty()
-    with pytest.raises(AttributeError, match="Unreadable attribute"):
-        d.__get__(5)
-
-
-def test_dynamic_property_fset_unset():
-    d = DynamicProperty()
-    with pytest.raises(AttributeError, match="set attribute"):
-        d.__set__(5, 7)
-
+# Channel
+class TestMultiFunctionality:
+    """Test the usage of children for different functionalities."""
+    class SomeFunctionality(Channel):
+        """This Functionality needs a prepended `id`."""
 
-def test_dynamic_property_unchanged_by_inheritance():
-    generic = GenericInstrument()
-    extended = ExtendedInstrument()
+        def insert_id(self, command, **kwargs):
+            return f"{self.id}:{command}"
 
-    generic.fake_ctrl = 50
-    assert generic.fake_ctrl == 10
-    extended.fake_ctrl = 50
-    assert extended.fake_ctrl == 10
+        voltage = Channel.control("Volt?", "Volt %f", "Set voltage in Volts.")
 
-    generic.fake_setting = 50
-    assert generic.read() == '10'
-    extended.fake_setting = 50
-    assert extended.read() == '10'
+    class InstrumentWithFunctionality(ChannelInstrument):
+        """Instrument with some functionality."""
 
-    generic.write('1')
-    assert generic.fake_measurement == 'X'
-    extended.write('1')
-    assert extended.fake_measurement == 'X'
-
-
-def test_dynamic_property_strict_raises():
-    strict = StrictExtendedInstrument()
-
-    with pytest.raises(ValueError):
-        strict.fake_ctrl = 50
-    with pytest.raises(ValueError):
-        strict.fake_setting = 50
-
-
-def test_dynamic_property_values_update_in_subclass():
-    newrange = NewRangeInstrument()
-
-    newrange.fake_ctrl = 50
-    assert newrange.fake_ctrl == 20
-
-    newrange.fake_setting = 50
-    assert newrange.read() == '20'
-
-    newrange.write('4')
-    assert newrange.fake_measurement == 'X'
-
-
-def test_dynamic_property_values_update_in_instance():
-    generic = GenericInstrument()
-
-    generic.fake_ctrl_values = (0, 33)
-    generic.fake_ctrl = 50
-    assert generic.fake_ctrl == 33
-
-    generic.fake_setting_values = (0, 33)
-    generic.fake_setting = 50
-    assert generic.read() == '33'
-
-    generic.fake_measurement_values = {'X': 7}
-    generic.write('7')
-    assert generic.fake_measurement == 'X'
-
-
-def test_dynamic_property_values_update_in_one_instance_leaves_other_unchanged():
-    generic1 = GenericInstrument()
-    generic2 = GenericInstrument()
-
-    generic1.fake_ctrl_values = (0, 33)
-    generic1.fake_ctrl = 50
-    generic2.fake_ctrl = 50
-    assert generic1.fake_ctrl == 33
-    assert generic2.fake_ctrl == 10
-
-
-def test_dynamic_property_reading_special_attributes_forbidden():
-    generic = GenericInstrument()
-    with pytest.raises(AttributeError):
-        generic.fake_ctrl_validator
+        def __init__(self, adapter, **kwargs):
+            super().__init__(adapter, **kwargs)
+            self.add_child(TestMultiFunctionality.SomeFunctionality, "X",
+                           collection="functions", prefix="f_")
+
+    def test_functionality_dict(self):
+        inst = TestMultiFunctionality.InstrumentWithFunctionality(ProtocolAdapter())
+        assert isinstance(inst.functions["X"], TestMultiFunctionality.SomeFunctionality)
+        assert inst.functions["X"] == inst.f_X
+
+    def test_functions_voltage_getter(self):
+        with expected_protocol(
+                TestMultiFunctionality.InstrumentWithFunctionality,
+                [("X:Volt?", "123.456")]
+        ) as inst:
+            assert inst.f_X.voltage == 123.456
+
+    def test_functions_voltage_setter(self):
+        with expected_protocol(
+                TestMultiFunctionality.InstrumentWithFunctionality,
+                [("X:Volt 123.456000", None)]
+        ) as inst:
+            inst.f_X.voltage = 123.456